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

11 KiB
Raw Blame History

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