Files
portal/docs/superpowers/specs/2026-06-27-admin-command-center-design.md
T
Дмитрий 1fd56e205b
Accessibility (Pa11y live) / a11y (push) Has been cancelled
docs(админка): спецификация + кликабельный макет «Командного центра»
Иерархический дашборд (3 уровня, drill-down). Этап 1: Командный центр +
Финансы + Здоровье (переиспользуют существующие экраны как L3). Этап 2: Лиды +
Заказ у поставщика. Механизм заказа задокументирован по коду (формула
SupplierQuotaAllocator: max(max_спрос, ceil(Σ/3))), без маржи (по решению владельца).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-27 12:37:28 +03:00

155 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Дизайн: 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`), layout `admin`.
Плитки — компоненты; клик меняет активную область (как в макете) или ведёт на
существующий экран. Стек — 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 — см. spec `2026-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-группой,
trait `SharesAdminPdo` уже глобальный) — корректные агрегаты на сидов.
- Тесты порогов светофоров (минус баланса → 🔴; 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ч 🔴.