2026-06-27 13:43:10 +03:00
< script setup lang = "ts" >
/**
* Админка → Командный центр (дашборд). Landing SaaS-админки: 4 плитки-светофора
2026-06-27 14:32:31 +03:00
* с проваливанием в детали (Уровень 2). Все 4 области — Финансы, Здоровье, Лиды,
* Заказ у поставщика — наполнены живыми данными (Этапы 1 + 2).
2026-06-27 13:43:10 +03:00
*
* Источник дизайна: 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 ,
2026-06-27 14:32:31 +03:00
getDashboardLeads ,
getDashboardSupply ,
2026-06-28 07:12:45 +03:00
getDashboardBalances ,
2026-06-28 08:28:47 +03:00
getDashboardClients ,
2026-06-27 13:43:10 +03:00
type DashboardSummary ,
type FinanceDetail ,
type HealthDetail ,
2026-06-27 14:32:31 +03:00
type LeadsDetail ,
type SupplyDetail ,
2026-06-28 07:12:45 +03:00
type BalancesDetail ,
2026-06-28 08:28:47 +03:00
type ClientsDetail ,
2026-06-28 09:54:09 +03:00
type PeriodParams ,
2026-06-27 13:43:10 +03:00
type Light ,
} from '../../api/adminDashboard' ;
const router = useRouter ( ) ;
2026-06-28 08:28:47 +03:00
type Area = 'fin' | 'health' | 'leads' | 'supply' | 'balances' | 'clients' ;
2026-06-28 09:54:09 +03:00
type Period = 'today' | '7d' | '30d' | '60d' | '90d' | 'custom' ;
2026-06-27 13:43:10 +03:00
2026-06-27 18:57:35 +03:00
const period = ref < Period > ( '7d' ) ;
2026-06-28 09:54:09 +03:00
const dateFrom = ref < string > ( '' ) ;
const dateTo = ref < string > ( '' ) ;
const showCustom = ref ( false ) ;
2026-06-27 13:43:10 +03:00
const selected = ref < Area > ( 'fin' ) ;
const summary = ref < DashboardSummary | null > ( null ) ;
const finance = ref < FinanceDetail | null > ( null ) ;
const health = ref < HealthDetail | null > ( null ) ;
2026-06-27 14:32:31 +03:00
const leads = ref < LeadsDetail | null > ( null ) ;
const supply = ref < SupplyDetail | null > ( null ) ;
2026-06-28 07:12:45 +03:00
const balances = ref < BalancesDetail | null > ( null ) ;
2026-06-28 08:28:47 +03:00
const clients = ref < ClientsDetail | null > ( null ) ;
2026-06-27 13:43:10 +03:00
const loading = ref ( false ) ;
const fetchError = ref ( false ) ;
2026-06-27 18:57:35 +03:00
const PERIODS : Array < { value : Period ; label : string } > = [
2026-06-27 13:43:10 +03:00
{ value : 'today' , label : 'Сегодня' } ,
{ value : '7d' , label : '7 дней' } ,
{ value : '30d' , label : '30 дней' } ,
2026-06-27 18:57:35 +03:00
{ value : '60d' , label : '60 дней' } ,
{ value : '90d' , label : '90 дней' } ,
2026-06-27 13:43:10 +03:00
] ;
2026-06-28 07:12:45 +03:00
/** Светофор-цвет → 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' ;
2026-06-27 13:43:10 +03:00
}
/** Подпись светофора Финансов на плитке. */
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' : 'есть проблемы' ;
}
2026-06-27 14:32:31 +03:00
/** Подпись светофора Лидов на плитке. */
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 } рассинхрон ` : 'ровно' ;
}
2026-06-28 07:12:45 +03:00
/** Подпись светофора Балансов на плитке. */
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 } дн. ` ;
}
2026-06-28 08:28:47 +03:00
/** Подпись светофора Клиентов на плитке. */
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-06-27 13:43:10 +03:00
/** Человеческие названия подсистем здоровья. */
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 {
2026-06-28 09:54:09 +03:00
const p = currentParams ( ) ;
2026-06-28 08:28:47 +03:00
const [ s , f , h , l , sup , bal , cl ] = await Promise . all ( [
2026-06-28 09:54:09 +03:00
getDashboardSummary ( p ) ,
getDashboardFinance ( p ) ,
2026-06-27 13:43:10 +03:00
getDashboardHealth ( ) ,
2026-06-27 14:32:31 +03:00
getDashboardLeads ( ) ,
getDashboardSupply ( ) ,
2026-06-28 07:12:45 +03:00
getDashboardBalances ( ) ,
2026-06-28 09:54:09 +03:00
getDashboardClients ( p ) ,
2026-06-27 13:43:10 +03:00
] ) ;
summary . value = s ;
finance . value = f ;
health . value = h ;
2026-06-27 14:32:31 +03:00
leads . value = l ;
supply . value = sup ;
2026-06-28 07:12:45 +03:00
balances . value = bal ;
2026-06-28 08:28:47 +03:00
clients . value = cl ;
2026-06-27 13:43:10 +03:00
} catch {
fetchError . value = true ;
} finally {
loading . value = false ;
}
}
2026-06-28 09:54:09 +03:00
/** Параметры периода для 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 } ;
}
2026-06-27 18:57:35 +03:00
function setPeriod ( p : Period ) {
2026-06-27 13:43:10 +03:00
period . value = p ;
2026-06-28 09:54:09 +03:00
showCustom . value = false ;
void load ( ) ;
}
/** Применить свой диапазон дат (обе даты обязательны). */
function applyCustom ( ) {
if ( ! dateFrom . value || ! dateTo . value ) return ;
period . value = 'custom' ;
2026-06-27 13:43:10 +03:00
void load ( ) ;
}
function selectArea ( area : Area ) {
selected . value = area ;
}
function openTenant ( subdomain : string ) {
router . push ( { name : 'admin-tenant-detail' , params : { code : subdomain } } ) ;
}
2026-06-28 10:14:47 +03:00
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 ;
}
2026-06-28 10:23:30 +03:00
/** Группа заказа → проекты у поставщика (с поиском по источнику). */
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' ) ;
}
2026-06-27 13:43:10 +03:00
onMounted ( load ) ;
2026-06-28 09:54:09 +03:00
defineExpose ( { period , dateFrom , dateTo , showCustom , selected , summary , finance , health , leads , supply , balances , clients , loading , fetchError , load } ) ;
2026-06-27 13:43:10 +03:00
< / 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 >
2026-06-28 09:54:09 +03:00
< 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 >
2026-06-27 13:43:10 +03:00
< / div >
< / div >
2026-06-28 09:54:09 +03:00
<!-- Свой диапазон дат -- >
< 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 >
2026-06-27 13:43:10 +03:00
< 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" >
2026-06-27 18:57:35 +03:00
< 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 >
2026-06-27 13:43:10 +03:00
< / 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 >
2026-06-27 14:32:31 +03:00
< 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" >
2026-06-27 18:57:35 +03:00
< span class = "text-medium-emphasis" > Получено от поставщика < / span >
< span class = "num font-weight-bold" > { { summary ? . leads . received _today ? ? '—' } } < / span >
2026-06-27 14:32:31 +03:00
< / div >
< div class = "d-flex justify-space-between mb-2" >
2026-06-27 18:57:35 +03:00
< span class = "text-medium-emphasis" > Зависших < / span >
< span
class = "num font-weight-bold"
: class = "{ 'text-error': (summary?.leads.stuck ?? 0) > 0 }"
> { { summary ? . leads . stuck ? ? '—' } } < / span >
2026-06-27 14:32:31 +03:00
< / div >
< div class = "d-flex justify-space-between" >
2026-06-27 18:57:35 +03:00
< span class = "text-medium-emphasis" > Нераспределённых < / span >
< span class = "num font-weight-bold" > { { summary ? . leads . unrouted ? ? '—' } } < / span >
2026-06-27 13:43:10 +03:00
< / div >
2026-06-27 14:32:31 +03:00
< div class = "tile__more" > Открыть лиды → < / div >
2026-06-27 13:43:10 +03:00
< / 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 >
2026-06-27 14:32:31 +03:00
< 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 >
2026-06-27 13:43:10 +03:00
< / div >
2026-06-27 14:32:31 +03:00
< div class = "tile__more" > Открыть заказ → < / div >
2026-06-27 13:43:10 +03:00
< / v-card-text >
< / v-card >
< / v-col >
2026-06-28 07:12:45 +03:00
<!-- БАЛАНСЫ СЕРВИСОВ -- >
< 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 >
2026-06-28 08:28:47 +03:00
<!-- КЛИЕНТЫ -- >
< 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 >
2026-06-27 13:43:10 +03:00
< / v-row >
<!-- DRILL : ФИНАНСЫ -- >
< v-card v-if = "selected === 'fin'" variant="outlined" class="drill mt-5" data-testid="drill-fin" >
2026-06-28 10:23:30 +03:00
< 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 >
2026-06-27 13:43:10 +03:00
< 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" >
2026-06-28 10:23:30 +03:00
< div class = "sub clk" @click ="openSubsystem(s.key)" >
2026-06-27 13:43:10 +03:00
< div class = "sub__nm" >
< v-icon :color = "lightColor(s.light)" size = "12" icon = "mdi-circle" class = "mr-2" / >
{ { subsystemLabel ( s . key ) } }
2026-06-28 10:23:30 +03:00
< v-icon size = "14" icon = "mdi-chevron-right" class = "ml-auto text-medium-emphasis" / >
2026-06-27 13:43:10 +03:00
< / div >
< div class = "sub__meta" > { { s . detail } } < / div >
< / div >
< / v-col >
< / v-row >
2026-06-28 10:23:30 +03:00
< p class = "text-medium-emphasis text-body-2 mt-2" >
Клик по подсистеме открывает соответствующий раздел ( инциденты , заливки , система ) .
< / p >
2026-06-27 13:43:10 +03:00
< / v-card-text >
< / v-card >
2026-06-27 14:32:31 +03:00
<!-- DRILL : ЛИДЫ -- >
2026-06-27 13:43:10 +03:00
< v-card v-else-if = "selected === 'leads'" variant="outlined" class="drill mt-5" data-testid="drill-leads" >
2026-06-27 14:32:31 +03:00
< 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 >
2026-06-27 18:57:35 +03:00
< 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 >
2026-06-27 14:32:31 +03:00
< 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 >
2026-06-28 10:14:47 +03:00
< div class = "d-flex align-center justify-space-between mb-2" >
< h4 class = "panel__h4 mb-0" > Последние лиды < / h4 >
2026-06-28 10:23:30 +03:00
< v-btn
variant = "text" size = "small" class = "text-none" color = "primary"
2026-06-28 10:14:47 +03:00
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 >
2026-06-27 14:32:31 +03:00
< p class = "text-medium-emphasis text-body-2 mt-2" >
2026-06-28 10:14:47 +03:00
Клик по лиду — карточка с полной цепочкой : откуда пришёл ( поставщик + канал + регион ) и кому ушёл .
Полный список с фильтрами и поиском — « Открыть все лиды » .
2026-06-27 14:32:31 +03:00
< / p >
2026-06-27 13:43:10 +03:00
< / v-card-text >
< / v-card >
2026-06-27 14:32:31 +03:00
<!-- DRILL : ЗАКАЗ У ПОСТАВЩИКА -- >
2026-06-28 07:12:45 +03:00
< v-card v-else-if = "selected === 'supply'" variant="outlined" class="drill mt-5" data-testid="drill-supply" >
2026-06-27 14:32:31 +03:00
< v -card -title class = "drill__head" > 📦 Заказ у поставщика — детали < / v-card-title >
< v-card-text >
2026-06-27 18:57:35 +03:00
< 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 >
2026-06-27 14:32:31 +03:00
< 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 >
2026-06-28 10:23:30 +03:00
< 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 >
2026-06-27 14:32:31 +03:00
< 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 >
2026-06-28 10:23:30 +03:00
< tr
v-for = "g in supply?.groups ?? []" :key="g.signal_type + '|' + g.identifier"
class = "clk" @click ="openSupplyGroup(g.identifier)" >
2026-06-27 14:32:31 +03:00
< 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 клиентов .
2026-06-28 10:23:30 +03:00
Рассинхрон = факт ≠ формула . Клик по группе → проекты у поставщика ( отфильтровать по источнику ) .
2026-06-27 14:32:31 +03:00
< / p >
2026-06-27 13:43:10 +03:00
< / v-card-text >
< / v-card >
2026-06-28 07:12:45 +03:00
<!-- DRILL : БАЛАНСЫ СЕРВИСОВ -- >
2026-06-28 08:28:47 +03:00
< v-card v-else-if = "selected === 'balances'" variant="outlined" class="drill mt-5" data-testid="drill-balances" >
2026-06-28 07:12:45 +03:00
< 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 >
2026-06-28 08:28:47 +03:00
<!-- DRILL : КЛИЕНТЫ -- >
< v-card v-else variant = "outlined" class = "drill mt-5" data -testid = " drill -clients " >
2026-06-28 10:23:30 +03:00
< 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 >
2026-06-28 08:28:47 +03:00
< 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 >
2026-06-27 13:43:10 +03:00
< / v-container >
< / template >
< style scoped >
. admin - dashboard {
max - width : 1200 px ;
}
. period - toggle {
display : flex ;
gap : 4 px ;
background : rgba ( 0 , 0 , 0 , 0.03 ) ;
border - radius : 9 px ;
padding : 3 px ;
}
. tile {
height : 100 % ;
cursor : pointer ;
transition : transform 0.15 s ease ;
}
. tile : hover {
transform : translateY ( - 2 px ) ;
}
. tile -- sel {
border - color : rgb ( var ( -- v - theme - primary ) ) ;
}
. tile _ _ico {
font - size : 18 px ;
}
. tile _ _more {
margin - top : 14 px ;
font - size : 12 px ;
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 : 16 px ;
font - weight : 700 ;
}
. kpi {
background : rgba ( 0 , 0 , 0 , 0.03 ) ;
border - radius : 12 px ;
padding : 14 px ;
}
. kpi _ _lab {
font - size : 12 px ;
opacity : 0.7 ;
}
. kpi _ _val {
font - size : 20 px ;
font - weight : 800 ;
margin - top : 4 px ;
}
. panel _ _h4 {
font - size : 13 px ;
text - transform : uppercase ;
letter - spacing : 0.5 px ;
opacity : 0.7 ;
margin - bottom : 12 px ;
}
. sub {
border : 1 px solid rgba ( 0 , 0 , 0 , 0.08 ) ;
border - radius : 12 px ;
padding : 14 px ;
height : 100 % ;
}
. sub _ _nm {
font - weight : 600 ;
font - size : 14 px ;
display : flex ;
align - items : center ;
}
. sub _ _meta {
font - size : 12 px ;
opacity : 0.7 ;
margin - top : 6 px ;
}
. clk : hover {
background : rgba ( 15 , 110 , 86 , 0.06 ) ;
cursor : pointer ;
}
< / style >