Files
portal/app/resources/js/views/admin/AdminDashboardView.vue
T
Дмитрий f30c6612c0 fix дашборд: достоверность метрик (здоровье/лиды/заказ) + периоды 60/90д
По сверке прод-данных с реальностью (часть чисел вводила в заблуждение):
- Финансы: +периоды 60 и 90 дней (крупные пополнения старше 30д теперь видны).
- Здоровье: «инциденты» больше не считают авто-лог ошибок джоб (summary
  'Автоматически:%') — раньше копилось 975 и держало красный ложно. Теперь:
  open_incidents = только реальные; добавлен job_errors_24h (повторяющиеся
  ошибки джоб за сутки) в подсистему queues.
- Лиды: убраны обманчивый «% доставки» (это было «обработано», не доставлено)
  и «нераспределённые по менеджерам» (менеджеры не используются). Добавлено
  «получено от поставщика сегодня»; доставлено = реально созданные сегодня сделки.
- Заказ: показаны дата снимка и полная картина (всего активных заказов /
  Σ лимита у поставщика) — сверка по снимку больше не выглядит занижено.

Тесты: admin-срез 87 зелёных, unit 3/3, фронт 10/10. stan 0, pint/eslint/
type-check/build чисто.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 18:57:35 +03:00

661 lines
30 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">
/**
* Админка → Командный центр (дашборд). Landing SaaS-админки: 4 плитки-светофора
* с проваливанием в детали (Уровень 2). Все 4 области — Финансы, Здоровье, Лиды,
* Заказ у поставщика — наполнены живыми данными (Этапы 1 + 2).
*
* Источник дизайна: web/admin-dashboard-mockup.html (Forest-палитра).
* Spec: docs/superpowers/specs/2026-06-27-admin-command-center-design.md
* Backend: AdminDashboardController (группа ['saas-admin','admin-db']).
*
* Клик по строке «внимание»/«топ» → /admin/tenants/{subdomain} (Уровень 3).
*/
import { onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
getDashboardSummary,
getDashboardFinance,
getDashboardHealth,
getDashboardLeads,
getDashboardSupply,
type DashboardSummary,
type FinanceDetail,
type HealthDetail,
type LeadsDetail,
type SupplyDetail,
type Light,
} from '../../api/adminDashboard';
const router = useRouter();
type Area = 'fin' | 'health' | 'leads' | 'supply';
type Period = 'today' | '7d' | '30d' | '60d' | '90d';
const period = ref<Period>('7d');
const selected = ref<Area>('fin');
const summary = ref<DashboardSummary | null>(null);
const finance = ref<FinanceDetail | null>(null);
const health = ref<HealthDetail | null>(null);
const leads = ref<LeadsDetail | null>(null);
const supply = ref<SupplyDetail | null>(null);
const loading = ref(false);
const fetchError = ref(false);
const PERIODS: Array<{ value: Period; label: string }> = [
{ value: 'today', label: 'Сегодня' },
{ value: '7d', label: '7 дней' },
{ value: '30d', label: '30 дней' },
{ value: '60d', label: '60 дней' },
{ value: '90d', label: '90 дней' },
];
/** Светофор-цвет → Vuetify-цвет. */
function lightColor(light: Light): string {
return light === 'green' ? 'success' : light === 'amber' ? 'warning' : 'error';
}
/** Подпись светофора Финансов на плитке. */
function financeLightLabel(): string {
const n = summary.value?.finance.negative_balance_count ?? 0;
return n > 0 ? `${n} в минусе` : 'в норме';
}
/** Подпись светофора Здоровья на плитке. */
function healthLightLabel(): string {
return summary.value?.health.light === 'green' ? 'OK' : 'есть проблемы';
}
/** Подпись светофора Лидов на плитке. */
function leadsLightLabel(): string {
const st = summary.value?.leads.stuck ?? 0;
return st > 0 ? `${st} зависших` : 'чисто';
}
/** Подпись светофора Заказа на плитке. */
function supplyLightLabel(): string {
const m = summary.value?.supply.mismatches ?? 0;
return m > 0 ? `${m} рассинхрон` : 'ровно';
}
/** Человеческие названия подсистем здоровья. */
const SUBSYSTEM_LABELS: Record<string, string> = {
queues: 'Очереди / джобы',
scheduler: 'Планировщик',
supplier_sync: 'Синхрон с поставщиком',
csv_drift: 'Сверка CSV (дрейф)',
webhooks: 'Вебхуки',
incidents: 'Инциденты',
};
function subsystemLabel(key: string): string {
return SUBSYSTEM_LABELS[key] ?? key;
}
/** «320000» → «320 000 ₽» (узкие неразрывные пробелы по разрядам). */
function rub(value: string | number | null | undefined): string {
const n = Math.round(Number(value ?? 0));
const sign = n < 0 ? '' : '';
const digits = Math.abs(n)
.toString()
.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return `${sign}${digits}`;
}
async function load() {
loading.value = true;
fetchError.value = false;
try {
const [s, f, h, l, sup] = await Promise.all([
getDashboardSummary(period.value),
getDashboardFinance(period.value),
getDashboardHealth(),
getDashboardLeads(),
getDashboardSupply(),
]);
summary.value = s;
finance.value = f;
health.value = h;
leads.value = l;
supply.value = sup;
} catch {
fetchError.value = true;
} finally {
loading.value = false;
}
}
function setPeriod(p: Period) {
period.value = p;
void load();
}
function selectArea(area: Area) {
selected.value = area;
}
function openTenant(subdomain: string) {
router.push({ name: 'admin-tenant-detail', params: { code: subdomain } });
}
onMounted(load);
defineExpose({ period, selected, summary, finance, health, leads, supply, loading, fetchError, load });
</script>
<template>
<v-container fluid class="admin-dashboard pa-6">
<!-- Шапка: заголовок + период -->
<div class="d-flex align-center justify-space-between mb-1 flex-wrap ga-3">
<h1 class="text-h5 font-weight-bold">Командный центр</h1>
<div class="period-toggle">
<v-btn
v-for="p in PERIODS"
:key="p.value"
:variant="period === p.value ? 'flat' : 'text'"
:color="period === p.value ? 'primary' : undefined"
size="small"
class="text-none"
:data-testid="`period-${p.value}`"
@click="setPeriod(p.value)"
>
{{ p.label }}
</v-btn>
</div>
</div>
<p class="text-body-2 text-medium-emphasis mb-4">
Одна картина всего портала. Кликните плитку провалитесь в детали.
</p>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
density="compact"
closable
class="mb-4"
data-testid="fetch-error-alert"
>
Не удалось загрузить данные дашборда. Попробуйте обновить.
</v-alert>
<!-- Плитки L1 -->
<v-row dense>
<!-- ФИНАНСЫ -->
<v-col cols="12" md="6">
<v-card
:variant="selected === 'fin' ? 'elevated' : 'outlined'"
:class="{ 'tile--sel': selected === 'fin' }"
class="tile"
data-testid="tile-fin"
@click="selectArea('fin')"
>
<v-card-text>
<div class="d-flex align-center mb-3">
<span class="tile__ico">💰</span>
<span class="text-subtitle-1 font-weight-bold ml-2">Финансы</span>
<v-chip
:color="lightColor(summary?.finance.light ?? 'green')"
size="small"
variant="tonal"
class="ml-auto"
>
{{ financeLightLabel() }}
</v-chip>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Пополнения за период</span>
<span class="num text-h6 font-weight-bold">{{ rub(summary?.finance.topups_rub) }}</span>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Списано за лиды</span>
<span class="num font-weight-bold">{{ rub(summary?.finance.charges_rub) }}</span>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Активных клиентов</span>
<span class="num font-weight-bold">{{ summary?.finance.active_clients ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between">
<span class="text-medium-emphasis">Новых за период</span>
<span class="num font-weight-bold text-success">+{{ summary?.finance.new_clients ?? 0 }}</span>
</div>
<div class="tile__more">Открыть финансы </div>
</v-card-text>
</v-card>
</v-col>
<!-- ЗДОРОВЬЕ -->
<v-col cols="12" md="6">
<v-card
:variant="selected === 'health' ? 'elevated' : 'outlined'"
:class="{ 'tile--sel': selected === 'health' }"
class="tile"
data-testid="tile-health"
@click="selectArea('health')"
>
<v-card-text>
<div class="d-flex align-center mb-3">
<span class="tile__ico"></span>
<span class="text-subtitle-1 font-weight-bold ml-2">Здоровье портала</span>
<v-chip
:color="lightColor(summary?.health.light ?? 'green')"
size="small"
variant="tonal"
class="ml-auto"
>
{{ healthLightLabel() }}
</v-chip>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Ошибок джоб за сутки</span>
<span
class="num font-weight-bold"
:class="{ 'text-error': (summary?.health.job_errors_24h ?? 0) > 0 }"
>{{ summary?.health.job_errors_24h ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Синхрон с поставщиком</span>
<span class="font-weight-bold">{{ summary?.health.last_sync_status ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between">
<span class="text-medium-emphasis">Открытых инцидентов</span>
<span class="num font-weight-bold">{{ summary?.health.open_incidents ?? '—' }}</span>
</div>
<div class="tile__more">Открыть здоровье </div>
</v-card-text>
</v-card>
</v-col>
<!-- ЛИДЫ (Этап 2) -->
<v-col cols="12" md="6">
<v-card
:variant="selected === 'leads' ? 'elevated' : 'outlined'"
:class="{ 'tile--sel': selected === 'leads' }"
class="tile"
data-testid="tile-leads"
@click="selectArea('leads')"
>
<v-card-text>
<div class="d-flex align-center mb-3">
<span class="tile__ico">🎯</span>
<span class="text-subtitle-1 font-weight-bold ml-2">Лиды</span>
<v-chip
:color="lightColor(summary?.leads.light ?? 'green')"
size="small"
variant="tonal"
class="ml-auto"
>
{{ leadsLightLabel() }}
</v-chip>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Доставлено сегодня</span>
<span class="num text-h6 font-weight-bold">{{ summary?.leads.delivered_today ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Получено от поставщика</span>
<span class="num font-weight-bold">{{ summary?.leads.received_today ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Зависших</span>
<span
class="num font-weight-bold"
:class="{ 'text-error': (summary?.leads.stuck ?? 0) > 0 }"
>{{ summary?.leads.stuck ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between">
<span class="text-medium-emphasis">Нераспределённых</span>
<span class="num font-weight-bold">{{ summary?.leads.unrouted ?? '—' }}</span>
</div>
<div class="tile__more">Открыть лиды </div>
</v-card-text>
</v-card>
</v-col>
<!-- ЗАКАЗ У ПОСТАВЩИКА (Этап 2) -->
<v-col cols="12" md="6">
<v-card
:variant="selected === 'supply' ? 'elevated' : 'outlined'"
:class="{ 'tile--sel': selected === 'supply' }"
class="tile"
data-testid="tile-supply"
@click="selectArea('supply')"
>
<v-card-text>
<div class="d-flex align-center mb-3">
<span class="tile__ico">📦</span>
<span class="text-subtitle-1 font-weight-bold ml-2">Заказ у поставщика</span>
<v-chip
:color="lightColor(summary?.supply.light ?? 'green')"
size="small"
variant="tonal"
class="ml-auto"
>
{{ supplyLightLabel() }}
</v-chip>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Просят клиенты (Σ/день)</span>
<span class="num text-h6 font-weight-bold">{{ summary?.supply.demand ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Надо по формуле</span>
<span class="num font-weight-bold">{{ summary?.supply.formula ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Заказали по факту</span>
<span class="num font-weight-bold">{{ summary?.supply.ordered ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between">
<span class="text-medium-emphasis">Групп с рассинхроном</span>
<span class="num font-weight-bold">{{ summary?.supply.mismatches ?? '—' }}</span>
</div>
<div class="tile__more">Открыть заказ </div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- DRILL: ФИНАНСЫ -->
<v-card v-if="selected === 'fin'" variant="outlined" class="drill mt-5" data-testid="drill-fin">
<v-card-title class="drill__head">💰 Финансы детали</v-card-title>
<v-card-text>
<v-row dense class="mb-4">
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Пополнения</div>
<div class="kpi__val num">{{ rub(finance?.kpi.topups_rub) }}</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Списано за лиды</div>
<div class="kpi__val num">{{ rub(finance?.kpi.charges_rub) }}</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Чистый приток</div>
<div class="kpi__val num text-success">{{ rub(finance?.kpi.net_inflow_rub) }}</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Клиентов в минусе</div>
<div class="kpi__val num text-error">{{ finance?.kpi.negative_balance_count ?? 0 }}</div>
</div>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<h4 class="panel__h4">🔴 Требуют внимания (баланс в минусе)</h4>
<v-table density="compact">
<thead>
<tr>
<th>Клиент</th>
<th class="text-right">Баланс</th>
</tr>
</thead>
<tbody>
<tr
v-for="t in finance?.attention ?? []"
:key="t.id"
class="clk"
@click="openTenant(t.subdomain)"
>
<td>{{ t.organization_name }}</td>
<td class="text-right num text-error">{{ rub(t.balance_rub) }}</td>
</tr>
<tr v-if="(finance?.attention?.length ?? 0) === 0">
<td colspan="2" class="text-medium-emphasis text-center">Никто не в минусе 🎉</td>
</tr>
</tbody>
</v-table>
</v-col>
<v-col cols="12" md="6">
<h4 class="panel__h4">Топ по обороту (пополнения за период)</h4>
<v-table density="compact">
<thead>
<tr>
<th>Клиент</th>
<th class="text-right">Пополнил</th>
</tr>
</thead>
<tbody>
<tr
v-for="t in finance?.top_by_turnover ?? []"
:key="t.id"
class="clk"
@click="openTenant(String(t.id))"
>
<td>{{ t.organization_name }}</td>
<td class="text-right num">{{ rub(t.topped_rub) }}</td>
</tr>
<tr v-if="(finance?.top_by_turnover?.length ?? 0) === 0">
<td colspan="2" class="text-medium-emphasis text-center">Нет пополнений за период</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- DRILL: ЗДОРОВЬЕ -->
<v-card v-else-if="selected === 'health'" variant="outlined" class="drill mt-5" data-testid="drill-health">
<v-card-title class="drill__head"> Здоровье портала детали</v-card-title>
<v-card-text>
<v-row dense>
<v-col v-for="s in health?.subsystems ?? []" :key="s.key" cols="12" sm="6" md="4">
<div class="sub">
<div class="sub__nm">
<v-icon :color="lightColor(s.light)" size="12" icon="mdi-circle" class="mr-2" />
{{ subsystemLabel(s.key) }}
</div>
<div class="sub__meta">{{ s.detail }}</div>
</div>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- DRILL: ЛИДЫ -->
<v-card v-else-if="selected === 'leads'" variant="outlined" class="drill mt-5" data-testid="drill-leads">
<v-card-title class="drill__head">🎯 Лиды детали</v-card-title>
<v-card-text>
<v-row dense>
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Доставлено сегодня</div>
<div class="kpi__val num">{{ leads?.kpi.delivered_today ?? 0 }}</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Получено от поставщика</div>
<div class="kpi__val num">{{ leads?.kpi.received_today ?? 0 }}</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Зависших</div>
<div class="kpi__val num" :class="{ 'text-error': (leads?.kpi.stuck ?? 0) > 0 }">
{{ leads?.kpi.stuck ?? 0 }}
</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Нераспределённых</div>
<div class="kpi__val num">{{ leads?.kpi.unrouted ?? 0 }}</div>
</div>
</v-col>
</v-row>
<p class="text-medium-emphasis text-body-2 mt-2">
«Доставлено» сделки, созданные клиентам сегодня. «Получено» лиды, пришедшие от поставщика
сегодня. «Зависшие» лиды без распределения дольше 4 часов (если их много проблема синхронизации).
</p>
</v-card-text>
</v-card>
<!-- DRILL: ЗАКАЗ У ПОСТАВЩИКА -->
<v-card v-else variant="outlined" class="drill mt-5" data-testid="drill-supply">
<v-card-title class="drill__head">📦 Заказ у поставщика детали</v-card-title>
<v-card-text>
<v-alert variant="tonal" density="compact" class="mb-4" type="info">
Всего у поставщика активных заказов: <b>{{ supply?.total_orders ?? 0 }}</b>
на <b>{{ supply?.total_limit ?? 0 }}</b> лидов/день.
Сверка ниже по снимку маршрутизации на <b>{{ supply?.snapshot_date ?? '—' }}</b>
(снимок делается каждый день в 18:02 МСК; в нём только проекты, активные на тот день).
</v-alert>
<v-row dense class="mb-4">
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Просят клиенты</div>
<div class="kpi__val num">{{ supply?.totals.demand ?? 0 }}</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Надо по формуле</div>
<div class="kpi__val num">{{ supply?.totals.formula ?? 0 }}</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Заказали по факту</div>
<div class="kpi__val num">{{ supply?.totals.ordered ?? 0 }}</div>
</div>
</v-col>
<v-col cols="6" md="3">
<div class="kpi">
<div class="kpi__lab">Рассинхронов</div>
<div class="kpi__val num" :class="{ 'text-error': (supply?.totals.mismatches ?? 0) > 0 }">
{{ supply?.totals.mismatches ?? 0 }}
</div>
</div>
</v-col>
</v-row>
<h4 class="panel__h4">По группам: спрос формула факт</h4>
<v-table density="compact">
<thead>
<tr>
<th>Группа</th>
<th class="text-right">Просят</th>
<th class="text-right">Формула</th>
<th class="text-right">Факт</th>
<th class="text-right">Совпадает?</th>
</tr>
</thead>
<tbody>
<tr v-for="g in supply?.groups ?? []" :key="g.signal_type + '|' + g.identifier">
<td>{{ g.identifier }} <span class="text-medium-emphasis">({{ g.signal_type }})</span></td>
<td class="text-right num">{{ g.demand }}</td>
<td class="text-right num">{{ g.formula }}</td>
<td class="text-right num">{{ g.ordered }}</td>
<td class="text-right">
<v-chip :color="g.in_sync ? 'success' : 'error'" size="x-small" variant="tonal">
{{ g.in_sync ? 'да' : 'рассинхрон' }}
</v-chip>
</td>
</tr>
<tr v-if="(supply?.groups?.length ?? 0) === 0">
<td colspan="5" class="text-medium-emphasis text-center">
Нет данных снимка маршрутизации (снимок делается в 18:02 МСК).
</td>
</tr>
</tbody>
</v-table>
<p class="text-medium-emphasis text-body-2 mt-2">
Формула заказа: max самого крупного клиента и сумма спроса ÷ 3 лид перепродаётся до 3 клиентов.
Рассинхрон = факт формула.
</p>
</v-card-text>
</v-card>
</v-container>
</template>
<style scoped>
.admin-dashboard {
max-width: 1200px;
}
.period-toggle {
display: flex;
gap: 4px;
background: rgba(0, 0, 0, 0.03);
border-radius: 9px;
padding: 3px;
}
.tile {
height: 100%;
cursor: pointer;
transition: transform 0.15s ease;
}
.tile:hover {
transform: translateY(-2px);
}
.tile--sel {
border-color: rgb(var(--v-theme-primary));
}
.tile__ico {
font-size: 18px;
}
.tile__more {
margin-top: 14px;
font-size: 12px;
font-weight: 600;
color: rgb(var(--v-theme-primary));
}
.num {
font-family: 'JetBrains Mono', 'Consolas', monospace;
font-variant-numeric: tabular-nums;
}
.drill__head {
font-size: 16px;
font-weight: 700;
}
.kpi {
background: rgba(0, 0, 0, 0.03);
border-radius: 12px;
padding: 14px;
}
.kpi__lab {
font-size: 12px;
opacity: 0.7;
}
.kpi__val {
font-size: 20px;
font-weight: 800;
margin-top: 4px;
}
.panel__h4 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.7;
margin-bottom: 12px;
}
.sub {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 12px;
padding: 14px;
height: 100%;
}
.sub__nm {
font-weight: 600;
font-size: 14px;
display: flex;
align-items: center;
}
.sub__meta {
font-size: 12px;
opacity: 0.7;
margin-top: 6px;
}
.clk:hover {
background: rgba(15, 110, 86, 0.06);
cursor: pointer;
}
</style>