Иерархический дашборд (3 уровня, drill-down). Этап 1: Командный центр + Финансы + Здоровье (переиспользуют существующие экраны как L3). Этап 2: Лиды + Заказ у поставщика. Механизм заказа задокументирован по коду (формула SupplierQuotaAllocator: max(max_спрос, ceil(Σ/3))), без маржи (по решению владельца). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 KiB
Дизайн: SaaS-админка «Командный центр» Лидерры (иерархический дашборд)
Дата: 2026-06-27
Авторы: Дмитрий (владелец) + Claude
Статус: концепция одобрена («в принципе пойдёт»), детализация для плана
Макет: web/admin-dashboard-mockup.html (кликабельный, фирменные цвета Forest)
Проблема / цель
Текущая админка — плоский список разделов (Тенанты, Биллинг, Инциденты…). Владельцу нужен командный центр: верхний экран с главными цифрами и светофорами, с которого можно проваливаться в детали (иерархия + вложенность).
Иерархия (3 уровня)
- Уровень 1 — Командный центр: landing-экран, 4 плитки со светофором (🟢/🟡/🔴) и 3–4 главными числами. Период: Сегодня / 7 дней / 30 дней.
- Уровень 2 — Область: клик по плитке → разворот области (KPI + графики + таблицы «требует внимания»).
- Уровень 3 — Карточка: клик по строке → существующий экран (карточка тенанта, инцидент, проект поставщика). Переиспользуем, не пишем заново.
Фазы (по решению владельца — этапами)
- Этап 1: Командный центр (L1 каркас) + области Финансы и Здоровье (L2), с проваливанием в существующие экраны (L3).
- Этап 2: области Лиды и Заказ у поставщика (L2) — там есть новые расчёты.
Не делаем (YAGNI)
- Маржа/себестоимость — владелец явно отказался. Прибыль/cost не показываем.
- Не трогаем RLS/роли/схему для Этапа 1 — все цифры читаются из существующих таблиц.
- Не переписываем существующие detail-экраны — они служат Уровнем 3.
Архитектура
- Frontend: новый Vue-роут
/admin(или/admin/dashboard) — landing «Командный центр» (компонентAdminDashboardView.vue), layoutadmin. Плитки — компоненты; клик меняет активную область (как в макете) или ведёт на существующий экран. Стек — Vue 3 + Vuetify 3 + Forest (Brandbook). - Backend: новый
AdminDashboardControllerс эндпоинтами агрегатов:GET /api/admin/dashboard— сводка для L1 (все 4 плитки, по периоду).GET /api/admin/dashboard/finance?period=— L2 Финансы.GET /api/admin/dashboard/health— L2 Здоровье.- (Этап 2)
GET /api/admin/dashboard/leads,GET /api/admin/dashboard/supplier-order.
- Эндпоинты — в группе
Route::middleware(['saas-admin','admin-db'])(та же admin-группа; cross-tenant доступ через подключениеpgsql_admin, уже введено фиксом 27.06 — см. spec2026-06-27-admin-db-connection-path-a-design.md). - Свежесть данных: запрос при открытии + кнопка «обновить»; период-тумблер пересчитывает агрегаты. Не realtime-push (не нужно).
Этап 1 — детальные требования
Плитка 💰 Финансы
L1 (сводка за период):
| Метрика | Источник |
|---|---|
| Пополнения за период | SUM(balance_transactions.amount_rub) WHERE type='topup' |
| Списано за лиды | SUM(ABS(amount_rub)) WHERE type='lead_charge' |
| Активных клиентов | tenants WHERE status='active' AND deleted_at IS NULL |
| Новых за период | COUNT(tenants) WHERE created_at IN period |
| Светофор | 🔴 если есть клиенты с balance_rub < 0; счётчик «N в минусе» |
L2 (детали): KPI (пополнения / списано / чистый приток = пополнения−списания /
клиентов в минусе); график пополнений vs списаний по дням (из
balance_transactions group by день); таблицы:
- «🔴 Требуют внимания» —
balance_rub < 0ИЛИ остаток баланса < 3 дней по среднему 7-дневному списанию (runway). Клик → карточка тенанта (L3). - «Топ по обороту» — клиенты по сумме пополнений за период.
- «Последние пополнения».
Пороги светофора: 🟢 нет минусов и ни у кого runway<3д; 🟡 есть runway<3д;
🔴 есть balance_rub<0.
Плитка ❤️ Здоровье портала
L1: общий светофор (худший из подсистем) + «открытых инцидентов N» + «последний сбой».
L2 — 6 подсистем, у каждой 🟢/🟡/🔴 + число + «когда»:
| Подсистема | Источник | 🔴 условие |
|---|---|---|
| Очереди / джобы | failed_jobs за сутки |
>0 упавших (🟡), spike (🔴) |
| Планировщик | scheduler_heartbeats.last_run_at vs ожидаемый интервал |
просрочка heartbeat |
| Синхрон с поставщиком | supplier_sync_runs последний status |
failed/aborted |
| Сверка CSV (дрейф) | supplier_csv_reconcile_log последний status/drift_ratio |
drift_alert |
| Вебхуки | failed_webhook_jobs WHERE resolved_at IS NULL |
unresolved > порога |
| Инциденты | incidents_log открытые (resolved_at IS NULL) |
есть high-severity |
- лента «последние события эксплуатации» (из supplier_sync_runs / reconcile / backups). Клик по подсистеме → существующие экраны Инциденты / Интеграция с поставщиком (L3).
Этап 2 — детальные требования
Плитка 🎯 Лиды
L1: доставлено сегодня; зависших; нераспределённых; % доставки.
Источники: project_routing_snapshots (expected_volume vs delivered_count),
supplier_leads (processed_at IS NULL = необработанные; error IS NOT NULL),
supplier_lead_deliveries, supplier_manual_sync_queue (pending).
🔴 условие: есть зависшие (необработанные supplier_leads старше N часов) или
нераспределённые (доставлены поставщиком, но не легли клиенту).
«Утечка» = закуплено у поставщика − доставлено клиентам (требует нового расчёта).
Плитка 📦 Заказ у поставщика
РЕАЛЬНЫЙ механизм (по коду, не выдумка):
- Спрос клиента по проекту =
COALESCE(projects.effective_daily_limit_today, daily_limit_target), замораживается вproject_routing_snapshots.daily_limitслепком в 18:02 МСК. - В 18:05 МСК
SyncSupplierProjectsJobгруппирует проекты по (signal_type, identifier) и считает заказ:order = max( max(спрос в группе) , ⌈ Σ(спрос) / 3 ⌉ )(SupplierQuotaAllocator::computeOrder) — потому что лид перепродаётся до 3 клиентов. - Заказ делится между площадками B1/B2/B3 (
distributeForPlatform) и пишется вsupplier_projects.current_limit(это «заказали по факту»), отправляется поставщику. - Итог прогона — в
supplier_sync_runs(groups_total / synced_ok / failed / …).
L1: Клиенты просят (Σ спроса/день); Надо по формуле; Заказали по факту;
Групп с рассинхроном.
L2 — таблица по группам/проектам: Клиенты просят (Σ snapshot.daily_limit по
группе) → Надо по формуле (computeOrder) → Заказали по факту (Σ
supplier_projects.current_limit по группе) → Совпадает? (🟢 факт=формула /
🔴 рассинхрон).
🔴 рассинхрон = «по факту» ≠ «по формуле» (лимит правили руками, или синхрон
18:05 не прошёл — supplier_sync_runs.status != ok / supplier_projects.sync_status='failed').
Новое: сейчас нет готовой сверки «заказ-по-формуле vs заказ-по-факту» (существующая CsvReconcile — про доставленные лиды, не про заказ). Этот расчёт делаем на лету (snapshot.daily_limit сгруппировать + computeOrder + сравнить с current_limit). Новой таблицы не требуется.
Тестирование
- Feature-тесты эндпоинтов
AdminDashboardController(под admin-группой, traitSharesAdminPdoуже глобальный) — корректные агрегаты на сидов. - Тесты порогов светофоров (минус баланса → 🔴; runway<3д → 🟡; sync failed → 🔴).
- Этап 2: тест расчёта «формула vs факт» для группы (mirror
SupplierQuotaAllocator). - Frontend (Vitest) — рендер плиток и переключение области (drill-down).
Открытые вопросы
- Кто ещё пользуется дашбордом, кроме владельца (роли)? — пока считаем только владелец/ops; отдельных ролей не вводим (saas-admin SSO ⏸ Б-1).
- Точные числовые пороги (runway дни, webhook unresolved, «зависший» возраст) — уточним при реализации; дефолты: runway<3д 🟡, webhook>0 🟡, зависший >2ч 🔴.