Files
portal/app/resources/js/views/admin/AdminDashboardView.vue
T
Дмитрий b2f08f28d5 feat(дашборд): Этап B+C — кликабельные группы Заказа, ссылки Здоровья, «Открыть всё»
B: строки групп «Заказа» кликабельны → проекты у поставщика (поиск по источнику) +
кнопка «Открыть проекты у поставщика». C: подсистемы «Здоровья» кликабельны →
Инциденты/Система/Интеграция с поставщиком; «Финансы» → Биллинг/Все клиенты;
«Клиенты» → Все клиенты. Сквозная вложенность дашборда замкнута до источников.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:23:30 +03:00

1086 lines
52 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,
getDashboardBalances,
getDashboardClients,
type DashboardSummary,
type FinanceDetail,
type HealthDetail,
type LeadsDetail,
type SupplyDetail,
type BalancesDetail,
type ClientsDetail,
type PeriodParams,
type Light,
} from '../../api/adminDashboard';
const router = useRouter();
type Area = 'fin' | 'health' | 'leads' | 'supply' | 'balances' | 'clients';
type Period = 'today' | '7d' | '30d' | '60d' | '90d' | 'custom';
const period = ref<Period>('7d');
const dateFrom = ref<string>('');
const dateTo = ref<string>('');
const showCustom = ref(false);
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 balances = ref<BalancesDetail | null>(null);
const clients = ref<ClientsDetail | 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-цвет. 'grey' = «нет данных / не удалось обновить». */
function lightColor(light: Light | 'grey'): string {
if (light === 'green') return 'success';
if (light === 'amber') return 'warning';
if (light === 'grey') return 'grey';
return '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} рассинхрон` : 'ровно';
}
/** Подпись светофора Балансов на плитке. */
function balancesLightLabel(): string {
const r = summary.value?.balances.red ?? 0;
if (r > 0) return `${r} на исходе`;
return summary.value?.balances.light === 'grey' ? 'нет данных' : 'в норме';
}
/** Человеческие названия и иконки внешних сервисов. */
const SERVICE_LABELS: Record<string, string> = {
dadata: 'DaData',
supplier: 'Поставщик',
yandex_cloud: 'Yandex Cloud',
};
const SERVICE_ICONS: Record<string, string> = {
dadata: '🧭',
supplier: '📦',
yandex_cloud: '☁️',
};
function serviceLabel(key: string): string {
return SERVICE_LABELS[key] ?? key;
}
function serviceIcon(key: string): string {
return SERVICE_ICONS[key] ?? '•';
}
/** «хватит на N дней» / «—». */
function daysLeftLabel(days: number | null): string {
return days === null ? '—' : `~${days} дн.`;
}
/** Подпись светофора Клиентов на плитке. */
function clientsLightLabel(): string {
const d = summary.value?.clients.dormant ?? 0;
return d > 0 ? `${d} спят` : 'все активны';
}
/** «2026-06-27 07:55» → «27.06 07:55»; null → «ни разу». */
function loginLabel(v: string | null): string {
if (!v) return 'ни разу';
const m = v.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}:\d{2})/);
return m ? `${m[3]}.${m[2]} ${m[4]}` : v;
}
/** Человеческие названия подсистем здоровья. */
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 p = currentParams();
const [s, f, h, l, sup, bal, cl] = await Promise.all([
getDashboardSummary(p),
getDashboardFinance(p),
getDashboardHealth(),
getDashboardLeads(),
getDashboardSupply(),
getDashboardBalances(),
getDashboardClients(p),
]);
summary.value = s;
finance.value = f;
health.value = h;
leads.value = l;
supply.value = sup;
balances.value = bal;
clients.value = cl;
} catch {
fetchError.value = true;
} finally {
loading.value = false;
}
}
/** Параметры периода для API: свой диапазон (приоритет) либо preset. */
function currentParams(): PeriodParams {
if (period.value === 'custom' && dateFrom.value && dateTo.value) {
return { date_from: dateFrom.value, date_to: dateTo.value };
}
return { period: period.value };
}
function setPeriod(p: Period) {
period.value = p;
showCustom.value = false;
void load();
}
/** Применить свой диапазон дат (обе даты обязательны). */
function applyCustom() {
if (!dateFrom.value || !dateTo.value) return;
period.value = 'custom';
void load();
}
function selectArea(area: Area) {
selected.value = area;
}
function openTenant(subdomain: string) {
router.push({ name: 'admin-tenant-detail', params: { code: subdomain } });
}
function openLead(id: number) {
router.push({ name: 'admin-lead-detail', params: { id: String(id) } });
}
function leadChannel(c: string | null): string {
return c === 'site' ? 'Сайт' : c === 'call' ? 'Звонок' : c === 'sms' ? 'SMS' : '—';
}
function leadTime(v: string): string {
const m = v.match(/^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}:\d{2})/);
return m ? `${m[3]}.${m[2]} ${m[4]}` : v;
}
/** Группа заказа → проекты у поставщика (с поиском по источнику). */
function openSupplyGroup(identifier: string) {
router.push({ path: '/admin/supplier-projects', query: { search: identifier } });
}
/** Подсистема «Здоровья» → соответствующий раздел админки. */
function openSubsystem(key: string) {
const map: Record<string, string> = {
incidents: '/admin/incidents',
queues: '/admin/system',
scheduler: '/admin/system',
supplier_sync: '/admin/supplier-integration',
csv_drift: '/admin/supplier-integration',
webhooks: '/admin/system',
};
router.push(map[key] ?? '/admin/system');
}
onMounted(load);
defineExpose({ period, dateFrom, dateTo, showCustom, selected, summary, finance, health, leads, supply, balances, clients, 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>
<v-btn
:variant="period === 'custom' ? 'flat' : 'text'"
:color="period === 'custom' ? 'primary' : undefined"
size="small"
class="text-none"
data-testid="period-custom-toggle"
@click="showCustom = !showCustom"
>
Свой период
</v-btn>
</div>
</div>
<!-- Свой диапазон дат -->
<div v-if="showCustom" class="d-flex align-center ga-2 mb-3 flex-wrap" data-testid="custom-range">
<v-text-field
v-model="dateFrom"
type="date"
label="С"
density="compact"
variant="outlined"
hide-details
style="max-width: 180px"
data-testid="date-from"
/>
<v-text-field
v-model="dateTo"
type="date"
label="По"
density="compact"
variant="outlined"
hide-details
style="max-width: 180px"
data-testid="date-to"
/>
<v-btn
color="primary"
size="small"
class="text-none"
:disabled="!dateFrom || !dateTo"
data-testid="apply-custom"
@click="applyCustom"
>
Применить
</v-btn>
</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-col cols="12" md="6">
<v-card
:variant="selected === 'balances' ? 'elevated' : 'outlined'"
:class="{ 'tile--sel': selected === 'balances' }"
class="tile"
data-testid="tile-balances"
@click="selectArea('balances')"
>
<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?.balances.light ?? 'grey')"
size="small"
variant="tonal"
class="ml-auto"
>
{{ balancesLightLabel() }}
</v-chip>
</div>
<div
v-for="s in balances?.services ?? []"
:key="s.service_key"
class="d-flex justify-space-between align-center mb-2"
>
<span class="text-medium-emphasis">
{{ serviceIcon(s.service_key) }} {{ serviceLabel(s.service_key) }}
</span>
<span class="d-flex align-center ga-2">
<span
class="num font-weight-bold"
:class="{ 'text-error': s.light === 'red' }"
>{{ s.ok ? rub(s.balance_amount) : 'нет данных' }}</span>
<v-icon :color="lightColor(s.light)" size="11" icon="mdi-circle" />
</span>
</div>
<div v-if="(balances?.services?.length ?? 0) === 0" class="text-medium-emphasis text-body-2">
Балансы ещё не собирались (сбор раз в сутки в 06:30 МСК).
</div>
<div class="tile__more">Открыть балансы </div>
</v-card-text>
</v-card>
</v-col>
<!-- КЛИЕНТЫ -->
<v-col cols="12" md="6">
<v-card
:variant="selected === 'clients' ? 'elevated' : 'outlined'"
:class="{ 'tile--sel': selected === 'clients' }"
class="tile"
data-testid="tile-clients"
@click="selectArea('clients')"
>
<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?.clients.light ?? 'green')"
size="small"
variant="tonal"
class="ml-auto"
>
{{ clientsLightLabel() }}
</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?.clients.total_active ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Новых за период</span>
<span class="num font-weight-bold text-success">+{{ summary?.clients.new_count ?? 0 }}</span>
</div>
<div class="d-flex justify-space-between mb-2">
<span class="text-medium-emphasis">Заходили за период</span>
<span class="num font-weight-bold">{{ summary?.clients.logged_in ?? '—' }}</span>
</div>
<div class="d-flex justify-space-between">
<span class="text-medium-emphasis">Спят (не заходят)</span>
<span
class="num font-weight-bold"
:class="{ 'text-warning': (summary?.clients.dormant ?? 0) > 0 }"
>{{ summary?.clients.dormant ?? '—' }}</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 d-flex align-center">
💰 Финансы детали
<v-spacer />
<v-btn variant="text" size="small" class="text-none" color="primary" to="/admin/billing">Биллинг </v-btn>
<v-btn variant="text" size="small" class="text-none" color="primary" to="/admin/tenants">Все клиенты </v-btn>
</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 clk" @click="openSubsystem(s.key)">
<div class="sub__nm">
<v-icon :color="lightColor(s.light)" size="12" icon="mdi-circle" class="mr-2" />
{{ subsystemLabel(s.key) }}
<v-icon size="14" icon="mdi-chevron-right" class="ml-auto text-medium-emphasis" />
</div>
<div class="sub__meta">{{ s.detail }}</div>
</div>
</v-col>
</v-row>
<p class="text-medium-emphasis text-body-2 mt-2">
Клик по подсистеме открывает соответствующий раздел (инциденты, заливки, система).
</p>
</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>
<div class="d-flex align-center justify-space-between mb-2">
<h4 class="panel__h4 mb-0">Последние лиды</h4>
<v-btn
variant="text" size="small" class="text-none" color="primary"
data-testid="open-all-leads" to="/admin/leads">
Открыть все лиды
</v-btn>
</div>
<v-table density="compact">
<thead>
<tr><th>Время</th><th>Канал</th><th>Источник</th><th>Поставщик</th><th>Телефон</th><th>Статус</th></tr>
</thead>
<tbody>
<tr v-for="r in leads?.recent ?? []" :key="r.id" class="clk" @click="openLead(r.id)">
<td class="num">{{ leadTime(r.received_at) }}</td>
<td>{{ leadChannel(r.channel) }}</td>
<td>{{ r.source ?? '—' }}</td>
<td>{{ r.platform }}</td>
<td class="num">{{ r.phone_masked }}</td>
<td>
<v-chip :color="r.delivered ? 'success' : r.processed ? 'warning' : 'info'" size="x-small" variant="tonal">
{{ r.delivered ? 'доставлен' : r.processed ? 'без получателя' : 'в обработке' }}
</v-chip>
</td>
</tr>
<tr v-if="(leads?.recent?.length ?? 0) === 0">
<td colspan="6" class="text-center text-medium-emphasis">Лидов пока нет</td>
</tr>
</tbody>
</v-table>
<p class="text-medium-emphasis text-body-2 mt-2">
Клик по лиду карточка с полной цепочкой: откуда пришёл (поставщик + канал + регион) и кому ушёл.
Полный список с фильтрами и поиском «Открыть все лиды».
</p>
</v-card-text>
</v-card>
<!-- DRILL: ЗАКАЗ У ПОСТАВЩИКА -->
<v-card v-else-if="selected === 'supply'" 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>
<div class="d-flex align-center justify-space-between mb-2">
<h4 class="panel__h4 mb-0">По группам: спрос формула факт</h4>
<v-btn
variant="text" size="small" class="text-none" color="primary"
data-testid="open-all-supply" to="/admin/supplier-projects">
Открыть проекты у поставщика
</v-btn>
</div>
<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"
class="clk" @click="openSupplyGroup(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>
<!-- DRILL: БАЛАНСЫ СЕРВИСОВ -->
<v-card v-else-if="selected === 'balances'" variant="outlined" class="drill mt-5" data-testid="drill-balances">
<v-card-title class="drill__head">💳 Балансы внешних сервисов детали</v-card-title>
<v-card-text>
<v-alert variant="tonal" density="compact" class="mb-4" type="info">
Баланс платных сервисов проверяется раз в сутки (06:30 МСК). Светофор: 🔴 мало денег
или хватит меньше 3 дней, 🟡 меньше 7 дней, не удалось обновить.
Кнопка «Пополнить» открывает страницу оплаты сервиса.
</v-alert>
<v-table density="compact">
<thead>
<tr>
<th>Сервис</th>
<th class="text-right">Баланс</th>
<th class="text-right">Хватит на</th>
<th class="text-center">Статус</th>
<th class="text-right">Оплата</th>
</tr>
</thead>
<tbody>
<tr v-for="s in balances?.services ?? []" :key="s.service_key">
<td>{{ serviceIcon(s.service_key) }} {{ serviceLabel(s.service_key) }}</td>
<td class="text-right num" :class="{ 'text-error': s.light === 'red' }">
{{ s.ok ? rub(s.balance_amount) : '—' }}
</td>
<td class="text-right num">{{ s.ok ? daysLeftLabel(s.days_left) : '—' }}</td>
<td class="text-center">
<v-chip
:color="lightColor(s.light)"
size="x-small"
variant="tonal"
:title="s.error ?? ''"
>
{{ s.ok ? 'ok' : 'не удалось обновить' }}
</v-chip>
</td>
<td class="text-right">
<v-btn
v-if="s.topup_url"
:href="s.topup_url"
target="_blank"
rel="noopener"
size="x-small"
color="primary"
variant="flat"
class="text-none"
:data-testid="`topup-${s.service_key}`"
>
Пополнить
</v-btn>
<span v-else class="text-medium-emphasis"></span>
</td>
</tr>
<tr v-if="(balances?.services?.length ?? 0) === 0">
<td colspan="5" class="text-medium-emphasis text-center">
Балансы ещё не собирались (сбор раз в сутки в 06:30 МСК).
</td>
</tr>
</tbody>
</v-table>
<p class="text-medium-emphasis text-body-2 mt-2">
«Хватит на N дней» оценка по среднему расходу за неделю, не точный прогноз. Если у сервиса
статус «не удалось обновить» показан последний известный баланс, проверьте доступ.
</p>
</v-card-text>
</v-card>
<!-- DRILL: КЛИЕНТЫ -->
<v-card v-else variant="outlined" class="drill mt-5" data-testid="drill-clients">
<v-card-title class="drill__head d-flex align-center">
👥 Клиенты активность за период
<v-spacer />
<v-btn
variant="text" size="small" class="text-none" color="primary"
data-testid="open-all-clients" to="/admin/tenants">Все клиенты </v-btn>
</v-card-title>
<v-card-text>
<v-row dense class="mb-4">
<v-col cols="6" md="2"><div class="kpi"><div class="kpi__lab">Всего активных</div><div class="kpi__val num">{{ clients?.kpi.total_active ?? 0 }}</div></div></v-col>
<v-col cols="6" md="2"><div class="kpi"><div class="kpi__lab">Новых</div><div class="kpi__val num text-success">+{{ clients?.kpi.new_count ?? 0 }}</div></div></v-col>
<v-col cols="6" md="2"><div class="kpi"><div class="kpi__lab">Заходили</div><div class="kpi__val num">{{ clients?.kpi.logged_in ?? 0 }}</div></div></v-col>
<v-col cols="6" md="2"><div class="kpi"><div class="kpi__lab">Получали лиды</div><div class="kpi__val num">{{ clients?.kpi.got_leads ?? 0 }}</div></div></v-col>
<v-col cols="6" md="2"><div class="kpi"><div class="kpi__lab">Платили</div><div class="kpi__val num">{{ clients?.kpi.paid ?? 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>Заходил</th><th class="text-right">Лидов/мес</th><th class="text-right">Баланс</th></tr>
</thead>
<tbody>
<tr v-for="c in clients?.new_clients ?? []" :key="c.id" class="clk" @click="openTenant(c.subdomain)">
<td>{{ c.organization_name }}</td>
<td :class="{ 'text-warning': !c.last_login_at }">{{ loginLabel(c.last_login_at) }}</td>
<td class="text-right num">{{ c.delivered_in_month }}</td>
<td class="text-right num">{{ rub(c.balance_rub) }}</td>
</tr>
<tr v-if="(clients?.new_clients?.length ?? 0) === 0">
<td colspan="4" class="text-medium-emphasis text-center">Новых за период нет</td>
</tr>
</tbody>
</v-table>
</v-col>
<v-col cols="12" md="6">
<h4 class="panel__h4">😴 Спящие (не заходят 14+ дней / ни разу)</h4>
<v-table density="compact">
<thead>
<tr><th>Клиент</th><th>Последний вход</th><th class="text-right">Баланс</th></tr>
</thead>
<tbody>
<tr v-for="c in clients?.dormant ?? []" :key="c.id" class="clk" @click="openTenant(c.subdomain)">
<td>{{ c.organization_name }}</td>
<td :class="{ 'text-warning': !c.last_login_at }">{{ loginLabel(c.last_login_at) }}</td>
<td class="text-right num">{{ rub(c.balance_rub) }}</td>
</tr>
<tr v-if="(clients?.dormant?.length ?? 0) === 0">
<td colspan="3" class="text-medium-emphasis text-center">Все клиенты активны 🎉</td>
</tr>
</tbody>
</v-table>
</v-col>
</v-row>
<p class="text-medium-emphasis text-body-2 mt-2">
«Заходили / Получали лиды / Платили» число клиентов с этим действием за выбранный период.
«Спящие» активные клиенты без входа дольше 14 дней (или ни разу не активировались). Клик по строке карточка клиента.
</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>