Files
portal/app/resources/js/views/admin/AdminDashboardView.vue
T

1086 lines
52 KiB
Vue
Raw Normal View History

<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>