Files
portal/app/resources/js/views/admin/AdminDashboardView.vue
T
Дмитрий 7c97980b3a fix(портал): читаемый текст зелёным и жёлтым — текстовые двойники брендовых цветов
Брендовые success и warning нечитаемы текстом: success даёт 4.25:1 на белой
карточке и 3.83:1 на ивори, warning — 2.25 и 2.03 при норме WCAG 2.1 AA 4.5:1.
Сами цвета не тронуты — остаются для заливок, иконок и рамок. Рядом добавлены
затемнённые текстовые двойники success-strong #256F46 и warning-strong #8A6410,
тон сохранён.

Все 15 мест text-success и 8 мест text-warning переведены на новые классы —
13 файлов: командный центр, биллинг и инциденты админки, система, тенанты,
интеграция с поставщиком, карточка тенанта, диалог баланса, рекламный
админ-экран, мастер рекламной кампании, массовые диалоги дней и регионов,
реквизиты в настройках.

Проверено: фронт 226 файлов / 1663 теста зелёные; Pa11y по всем 24 адресам —
рекламные и ВСЕ админские экраны дают 0 ошибок. Побочно вскрылось 12 старых
недочётов на клиентских экранах — записаны в находку, к этой правке отношения
не имеют.

Боевого не касается, в main не влито.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 11:02:26 +03:00

1313 lines
62 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 { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import {
getDashboardSummary,
getDashboardFinance,
getDashboardHealth,
getDashboardLeads,
getDashboardSupply,
getDashboardBalances,
getDashboardClients,
refreshDashboardBalances,
type DashboardSummary,
type FinanceDetail,
type HealthDetail,
type LeadsDetail,
type SupplyDetail,
type BalancesDetail,
type ClientsDetail,
type PeriodParams,
type Light,
} from '../../api/adminDashboard';
import { isProxy, proxyMoney, proxyStatus } from './adminDashboardHelpers';
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 balancesRefreshing = ref(false);
const refreshingKey = ref<string | null>(null);
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',
email: 'Почта',
yookassa: 'ЮKassa',
captcha: 'Капча',
exa_tunnel: 'EXA-тоннель',
aitunnel: 'AITUNNEL',
exa: 'EXA',
xfetch: 'xfetch',
self_render: 'Свой рендер',
sales_finder: 'Поиск клиентов',
proxy_market: 'Прокси автоподбора',
smsc: 'СМС-центр',
};
const SERVICE_ICONS: Record<string, string> = {
dadata: '🧭',
supplier: '📦',
yandex_cloud: '☁️',
email: '✉️',
yookassa: '💳',
captcha: '🛡',
exa_tunnel: '🔌',
aitunnel: '🤖',
exa: '🔎',
xfetch: '🌐',
self_render: '🖥',
sales_finder: '🐍',
proxy_market: '🛰',
smsc: '📲',
};
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} дн.`;
}
/** Сервисы БЕЗ денежного баланса — следим только за живостью (не за деньгами). */
const LIVENESS_ONLY_KEYS = new Set(['yookassa', 'captcha', 'self_render', 'sales_finder', 'exa_tunnel']);
function isLivenessOnly(key: string): boolean {
return LIVENESS_ONLY_KEYS.has(key);
}
/** Слово о доступности сервиса по цвету светофора. */
function livenessWord(light: string): string {
if (light === 'green') return 'жив';
if (light === 'red') return 'не отвечает';
return 'выключено'; // grey
}
/** Карточка «Поиск клиентов» = служба поиска + её платные помощники xfetch/Keyso в одной плитке. */
const GROUP_KEY = '__client_search__';
const GROUP_MEMBERS = ['sales_finder', 'xfetch', 'keyso'];
const MEMBER_LABELS: Record<string, string> = { sales_finder: 'служба', xfetch: 'xfetch', keyso: 'Keyso' };
const LIGHT_RANK: Record<string, number> = { green: 0, grey: 1, amber: 2, red: 3 };
type ServiceItem = BalancesDetail['services'][number];
type GroupMember = { key: string; label: string; light: Light | 'grey'; topup_url?: string | null };
type DisplayRow = ServiceItem & { is_group?: boolean; members?: GroupMember[] };
function worstLight(lights: Array<Light | 'grey'>): Light | 'grey' {
return lights.reduce<Light | 'grey'>((w, l) => ((LIGHT_RANK[l] ?? 0) > (LIGHT_RANK[w] ?? 0) ? l : w), 'green');
}
/** Строки плитки, где 3 ключа поиска клиентов слиты в одну карточку-группу. */
const displayServices = computed<DisplayRow[]>(() => {
const list = balances.value?.services ?? [];
const members = list.filter((s) => GROUP_MEMBERS.includes(s.service_key));
if (members.length === 0) return list;
const group: DisplayRow = {
service_key: GROUP_KEY,
is_group: true,
light: worstLight(members.map((m) => m.light)),
balance_amount: null,
currency: 'RUB',
daily_spend_estimate: null,
ok: true,
days_left: null,
error: null,
topup_url: null,
checked_at:
members
.map((m) => m.checked_at)
.filter((v): v is string => Boolean(v))
.sort()
.pop() ?? null,
members: GROUP_MEMBERS.map((k) => {
const row = members.find((m) => m.service_key === k);
return { key: k, label: MEMBER_LABELS[k], light: row?.light ?? 'grey', topup_url: row?.topup_url ?? null };
}),
};
const out: DisplayRow[] = [];
let inserted = false;
for (const s of list) {
if (GROUP_MEMBERS.includes(s.service_key)) {
if (!inserted) {
out.push(group);
inserted = true;
}
continue;
}
out.push(s);
}
return out;
});
/** Слово о состоянии группы «Поиск клиентов». */
function groupStatusWord(row: DisplayRow): string {
const members = row.members ?? [];
const down = members.filter((m) => m.light === 'red').map((m) => m.label);
if (down.length) return 'не отвечает: ' + down.join(', ');
const grey = members.filter((m) => m.light === 'grey');
if (grey.length && row.light !== 'green') return 'частично выключено';
return 'жив';
}
/** Обновить всю группу «Поиск клиентов» одной кнопкой (три ключа разом). */
async function refreshGroup() {
refreshingKey.value = GROUP_KEY;
try {
balances.value = await refreshDashboardBalances(GROUP_MEMBERS);
} catch {
/* тихо */
} finally {
refreshingKey.value = null;
}
}
type ServiceRow = {
service_key: string;
balance_amount: string | null;
light: string;
ok: boolean;
expires_at?: string | null;
};
/** Колонка «Баланс» (и плитка, и детализация) — ТОЛЬКО деньги; у сервисов-живости всегда «—». */
function serviceMoney(s: ServiceRow): string {
if (isProxy(s.service_key)) return proxyMoney(s);
if (isLivenessOnly(s.service_key)) return '—';
return s.ok && s.balance_amount !== null ? rub(s.balance_amount) : '—';
}
/** Колонка «Статус» в детализации — доступность/здоровье сервиса. */
function serviceStatus(s: ServiceRow): string {
if (isProxy(s.service_key)) return proxyStatus(s.light);
if (isLivenessOnly(s.service_key)) return livenessWord(s.light);
if (s.ok && s.balance_amount === null) return 'жив'; // платный сервис без ручки баланса
return s.ok ? 'ok' : 'не удалось обновить';
}
/** Ячейка «осталось»: для прокси — дни до конца срока; для платных — «хватит на N дней». */
function remainCell(s: DisplayRow): string {
if (s.is_group) return '—';
if (isProxy(s.service_key)) return daysLeftLabel(s.days_left);
return s.balance_amount !== null && s.ok ? daysLeftLabel(s.days_left) : '—';
}
/** Подпись светофора Клиентов на плитке. */
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;
}
/** «2026-07-16T12:53:...» → «12:53»; null → «—». */
function checkedLabel(v: string | null): string {
if (!v) return '—';
const m = v.match(/[T ](\d{2}:\d{2})/);
return m ? m[1] : 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 refreshLiveBalances() {
balancesRefreshing.value = true;
try {
const fresh = await refreshDashboardBalances();
balances.value = fresh;
if (summary.value) {
summary.value.balances.light = fresh.light;
summary.value.balances.red = fresh.services.filter((s) => s.ok && s.light === 'red').length;
}
} catch {
/* тихо: остаётся кэш */
} finally {
balancesRefreshing.value = false;
}
}
/** Ручное обновление одного сервиса по кнопке ⟳. */
async function refreshOneService(key: string) {
refreshingKey.value = key;
try {
balances.value = await refreshDashboardBalances(key);
} catch {
/* тихо */
} finally {
refreshingKey.value = null;
}
}
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;
}
void refreshLiveBalances(); // фон: лёгкие сервисы вживую при каждом открытии
}
/** Параметры периода для 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, refreshLiveBalances, refreshOneService, balancesRefreshing, refreshingKey,
displayServices, refreshGroup,
});
</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-strong">+{{ 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 displayServices"
:key="s.service_key"
class="d-flex justify-space-between align-center mb-2"
>
<span class="text-medium-emphasis">
<template v-if="s.is_group">🐍 Поиск клиентов</template>
<template v-else>{{ serviceIcon(s.service_key) }} {{ serviceLabel(s.service_key) }}</template>
</span>
<span class="d-flex align-center ga-2">
<template v-if="s.is_group">
<span class="text-caption text-medium-emphasis">{{ groupStatusWord(s) }}</span>
</template>
<template v-else>
<span
class="num font-weight-bold"
:class="{ 'text-error': !isLivenessOnly(s.service_key) && s.light === 'red' }"
>{{ serviceMoney(s) }}</span>
<span class="text-caption text-medium-emphasis">{{ serviceStatus(s) }}</span>
</template>
<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-strong">+{{ 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-strong': (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-strong">{{ 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 дней, не удалось проверить или выключен. При переходе в 🔴 приходит
письмо на ops-адрес.
</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>
<th class="text-right">Оплата</th>
</tr>
</thead>
<tbody>
<tr v-for="s in displayServices" :key="s.service_key">
<td>
<template v-if="s.is_group">
🐍 Поиск клиентов
<div class="text-caption text-medium-emphasis">
<span v-for="m in s.members" :key="m.key" class="mr-2">
<v-icon :color="lightColor(m.light)" size="9" icon="mdi-circle" /> {{ m.label }}
<a
v-if="m.topup_url"
:href="m.topup_url"
target="_blank"
rel="noopener"
class="text-primary ml-1"
:data-testid="`topup-${m.key}`"
>пополнить</a>
</span>
</div>
</template>
<template v-else>{{ serviceIcon(s.service_key) }} {{ serviceLabel(s.service_key) }}</template>
</td>
<td class="text-right num" :class="{ 'text-error': !s.is_group && s.balance_amount !== null && s.light === 'red' }">
{{ s.is_group ? '—' : serviceMoney(s) }}
</td>
<td class="text-right num">{{ remainCell(s) }}</td>
<td class="text-center">
<v-chip
:color="lightColor(s.light)"
size="x-small"
variant="tonal"
:title="s.error ?? ''"
>
{{ s.is_group ? groupStatusWord(s) : serviceStatus(s) }}
</v-chip>
</td>
<td class="text-right text-medium-emphasis text-caption">
{{ checkedLabel(s.checked_at) }}
<v-btn
:icon="true"
size="x-small"
variant="text"
:loading="refreshingKey === s.service_key"
:data-testid="`refresh-${s.service_key}`"
title="Обновить сейчас"
@click="s.is_group ? refreshGroup() : refreshOneService(s.service_key)"
></v-btn>
</td>
<td class="text-right">
<v-btn
v-if="!s.is_group && 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="6" 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-strong">+{{ 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-strong': !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-strong': !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>