f30c6612c0
По сверке прод-данных с реальностью (часть чисел вводила в заблуждение): - Финансы: +периоды 60 и 90 дней (крупные пополнения старше 30д теперь видны). - Здоровье: «инциденты» больше не считают авто-лог ошибок джоб (summary 'Автоматически:%') — раньше копилось 975 и держало красный ложно. Теперь: open_incidents = только реальные; добавлен job_errors_24h (повторяющиеся ошибки джоб за сутки) в подсистему queues. - Лиды: убраны обманчивый «% доставки» (это было «обработано», не доставлено) и «нераспределённые по менеджерам» (менеджеры не используются). Добавлено «получено от поставщика сегодня»; доставлено = реально созданные сегодня сделки. - Заказ: показаны дата снимка и полная картина (всего активных заказов / Σ лимита у поставщика) — сверка по снимку больше не выглядит занижено. Тесты: admin-срез 87 зелёных, unit 3/3, фронт 10/10. stan 0, pint/eslint/ type-check/build чисто. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
661 lines
30 KiB
Vue
661 lines
30 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,
|
||
type DashboardSummary,
|
||
type FinanceDetail,
|
||
type HealthDetail,
|
||
type LeadsDetail,
|
||
type SupplyDetail,
|
||
type Light,
|
||
} from '../../api/adminDashboard';
|
||
|
||
const router = useRouter();
|
||
|
||
type Area = 'fin' | 'health' | 'leads' | 'supply';
|
||
type Period = 'today' | '7d' | '30d' | '60d' | '90d';
|
||
|
||
const period = ref<Period>('7d');
|
||
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 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-цвет. */
|
||
function lightColor(light: Light): string {
|
||
return light === 'green' ? 'success' : light === 'amber' ? 'warning' : '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} рассинхрон` : 'ровно';
|
||
}
|
||
|
||
/** Человеческие названия подсистем здоровья. */
|
||
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 [s, f, h, l, sup] = await Promise.all([
|
||
getDashboardSummary(period.value),
|
||
getDashboardFinance(period.value),
|
||
getDashboardHealth(),
|
||
getDashboardLeads(),
|
||
getDashboardSupply(),
|
||
]);
|
||
summary.value = s;
|
||
finance.value = f;
|
||
health.value = h;
|
||
leads.value = l;
|
||
supply.value = sup;
|
||
} catch {
|
||
fetchError.value = true;
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function setPeriod(p: Period) {
|
||
period.value = p;
|
||
void load();
|
||
}
|
||
|
||
function selectArea(area: Area) {
|
||
selected.value = area;
|
||
}
|
||
|
||
function openTenant(subdomain: string) {
|
||
router.push({ name: 'admin-tenant-detail', params: { code: subdomain } });
|
||
}
|
||
|
||
onMounted(load);
|
||
|
||
defineExpose({ period, selected, summary, finance, health, leads, supply, 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>
|
||
</div>
|
||
</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-row>
|
||
|
||
<!-- DRILL: ФИНАНСЫ -->
|
||
<v-card v-if="selected === 'fin'" variant="outlined" class="drill mt-5" data-testid="drill-fin">
|
||
<v-card-title class="drill__head">💰 Финансы — детали</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">
|
||
<div class="sub__nm">
|
||
<v-icon :color="lightColor(s.light)" size="12" icon="mdi-circle" class="mr-2" />
|
||
{{ subsystemLabel(s.key) }}
|
||
</div>
|
||
<div class="sub__meta">{{ s.detail }}</div>
|
||
</div>
|
||
</v-col>
|
||
</v-row>
|
||
</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>
|
||
<p class="text-medium-emphasis text-body-2 mt-2">
|
||
«Доставлено» — сделки, созданные клиентам сегодня. «Получено» — лиды, пришедшие от поставщика
|
||
сегодня. «Зависшие» — лиды без распределения дольше 4 часов (если их много — проблема синхронизации).
|
||
</p>
|
||
</v-card-text>
|
||
</v-card>
|
||
|
||
<!-- DRILL: ЗАКАЗ У ПОСТАВЩИКА -->
|
||
<v-card v-else 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>
|
||
<h4 class="panel__h4">По группам: спрос → формула → факт</h4>
|
||
<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">
|
||
<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>
|
||
</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>
|