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