# Дизайн: 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ч 🔴.
