b2f08f28d5
B: строки групп «Заказа» кликабельны → проекты у поставщика (поиск по источнику) + кнопка «Открыть проекты у поставщика». C: подсистемы «Здоровья» кликабельны → Инциденты/Система/Интеграция с поставщиком; «Финансы» → Биллинг/Все клиенты; «Клиенты» → Все клиенты. Сквозная вложенность дашборда замкнута до источников. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1086 lines
52 KiB
Vue
1086 lines
52 KiB
Vue
<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>
|