ui(super-admin): SaaS-метрики на главной системной консоли (placeholders)

Food Market — SaaS для розницы, SuperAdmin это владелец платформы,
а не сотрудник магазина. Операционные метрики магазинов («Товаров
29540», «Приёмок за месяц») для него бесполезны — это для tenant
dashboard'а конкретной орги.

KPI блок на /super-admin переработан под кабинет SaaS-провайдера:

Top row (4 карточки):
- Организаций (как было — клиентская база)
- Платящих клиентов — placeholder, accent emerald, muted
- MRR (₸ / мес) — placeholder, accent violet, muted
- Должники — placeholder, accent rose, muted

Second row (2 карточки):
- Пользователей (всего/активных)
- Регистраций за 30 дней — реальное значение
  (COUNT Organizations WHERE CreatedAt >= now-30d)

Заглушки получили проп muted=true: фон чуть серее (slate-50/60),
значение «—» более бледным slate-400, иконка остаётся полноцветной
чтобы было видно «здесь будут данные после Phase 4». В hint —
«Скоро · после внедрения биллинга».

API: DashboardStats потерял TotalProducts/TotalSuppliesThisMonth,
получил RegistrationsLast30Days.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 15:47:13 +05:00
parent 7c161a138b
commit 21c2ca89fe
2 changed files with 37 additions and 12 deletions

View file

@ -29,11 +29,14 @@ public async Task<ActionResult<SetupStatusDto>> GetSetupStatus(CancellationToken
public record DashboardStats( public record DashboardStats(
int TotalOrgs, int ActiveOrgs, int ArchivedOrgs, int TotalOrgs, int ActiveOrgs, int ArchivedOrgs,
int TotalUsers, int ActiveUsers, int TotalUsers, int ActiveUsers,
int TotalProducts, int TotalSuppliesThisMonth); int RegistrationsLast30Days);
[HttpGet("dashboard")] [HttpGet("dashboard")]
public async Task<ActionResult<DashboardStats>> Dashboard(CancellationToken ct) public async Task<ActionResult<DashboardStats>> Dashboard(CancellationToken ct)
{ {
// Метрики SuperAdmin'а — кабинет SaaS-владельца, не операционные
// показатели магазинов. Биллинговые KPI (MRR, должники, платящие)
// считаем на UI как заглушки — отдельный модуль подписки в Phase 4+.
var monthAgo = DateTime.UtcNow.AddDays(-30); var monthAgo = DateTime.UtcNow.AddDays(-30);
return new DashboardStats( return new DashboardStats(
TotalOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(ct), TotalOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(ct),
@ -41,8 +44,7 @@ public async Task<ActionResult<DashboardStats>> Dashboard(CancellationToken ct)
ArchivedOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.IsArchived, ct), ArchivedOrgs: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.IsArchived, ct),
TotalUsers: await _db.Users.CountAsync(ct), TotalUsers: await _db.Users.CountAsync(ct),
ActiveUsers: await _db.Users.CountAsync(u => u.IsActive, ct), ActiveUsers: await _db.Users.CountAsync(u => u.IsActive, ct),
TotalProducts: await _db.Products.IgnoreQueryFilters().CountAsync(ct), RegistrationsLast30Days: await _db.Organizations.IgnoreQueryFilters().CountAsync(o => o.CreatedAt >= monthAgo, ct));
TotalSuppliesThisMonth: await _db.Supplies.IgnoreQueryFilters().CountAsync(s => s.Date >= monthAgo, ct));
} }
public record AuditRow( public record AuditRow(

View file

@ -1,15 +1,17 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
Building2, Users, Package, ShoppingCart, FileClock, Building2, Users, FileClock,
CheckCircle2, AlertCircle, Activity, Inbox, ShieldCheck, CheckCircle2, AlertCircle, Activity, Inbox, ShieldCheck,
CreditCard, TrendingUp, AlertTriangle, UserPlus,
} from 'lucide-react' } from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
interface DashboardStats { interface DashboardStats {
totalOrgs: number; activeOrgs: number; archivedOrgs: number totalOrgs: number; activeOrgs: number; archivedOrgs: number
totalUsers: number; activeUsers: number totalUsers: number; activeUsers: number
totalProducts: number; totalSuppliesThisMonth: number registrationsLast30Days: number
} }
interface AuditRow { interface AuditRow {
@ -19,26 +21,36 @@ interface AuditRow {
const fmt = new Intl.NumberFormat('ru') const fmt = new Intl.NumberFormat('ru')
function Kpi({ icon: Icon, label, value, hint, accent = 'indigo' }: { function Kpi({ icon: Icon, label, value, hint, accent = 'indigo', muted = false }: {
icon: React.ComponentType<{ className?: string }> icon: React.ComponentType<{ className?: string }>
label: string; value: string | number; hint?: string label: string; value: string | number; hint?: string
accent?: 'indigo' | 'emerald' | 'amber' | 'sky' accent?: 'indigo' | 'emerald' | 'amber' | 'sky' | 'rose' | 'violet'
/** «Скоро» заглушка под будущий биллинг. Тонировка фона сильнее,
* но иконка остаётся цветной чтобы было ясно «здесь будет данные». */
muted?: boolean
}) { }) {
const accents: Record<string, string> = { const accents: Record<string, string> = {
indigo: 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/40 dark:text-indigo-300', indigo: 'bg-indigo-100 text-indigo-600 dark:bg-indigo-900/40 dark:text-indigo-300',
emerald: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-300', emerald: 'bg-emerald-100 text-emerald-600 dark:bg-emerald-900/40 dark:text-emerald-300',
amber: 'bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300', amber: 'bg-amber-100 text-amber-600 dark:bg-amber-900/40 dark:text-amber-300',
sky: 'bg-sky-100 text-sky-600 dark:bg-sky-900/40 dark:text-sky-300', sky: 'bg-sky-100 text-sky-600 dark:bg-sky-900/40 dark:text-sky-300',
rose: 'bg-rose-100 text-rose-600 dark:bg-rose-900/40 dark:text-rose-300',
violet: 'bg-violet-100 text-violet-600 dark:bg-violet-900/40 dark:text-violet-300',
} }
return ( return (
<div className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-4 transition hover:shadow-sm hover:border-slate-300 dark:hover:border-slate-700"> <div className={cn(
'rounded-xl border bg-white dark:bg-slate-900 p-4 transition hover:shadow-sm',
muted
? 'border-slate-200/70 dark:border-slate-800/70 bg-slate-50/60 dark:bg-slate-900/60'
: 'border-slate-200 dark:border-slate-800 hover:border-slate-300 dark:hover:border-slate-700',
)}>
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${accents[accent]}`}> <div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${accents[accent]}`}>
<Icon className="w-5 h-5" /> <Icon className="w-5 h-5" />
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[11px] uppercase tracking-wide text-slate-500">{label}</div> <div className="text-[11px] uppercase tracking-wide text-slate-500">{label}</div>
<div className="text-2xl font-bold mt-0.5 leading-tight">{value}</div> <div className={cn('text-2xl font-bold mt-0.5 leading-tight', muted && 'text-slate-400')}>{value}</div>
{hint && <div className="text-xs text-slate-400 mt-0.5">{hint}</div>} {hint && <div className="text-xs text-slate-400 mt-0.5">{hint}</div>}
</div> </div>
</div> </div>
@ -98,14 +110,25 @@ export function SuperAdminDashboardPage() {
</div> </div>
</section> </section>
{/* KPI cards */} {/* KPI: SaaS-владелец платформы. Главный ряд клиентская база
* и биллинг (последние 3 заглушки до Phase 4 «Подписки»).
* Второй ряд инфраструктура учёток. Операционные метрики
* магазинов (товары/приёмки) выкинуты это для tenant-дашборда. */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4">
<Kpi icon={Building2} label="Организаций" value={fmt.format(data?.totalOrgs ?? 0)} <Kpi icon={Building2} label="Организаций" value={fmt.format(data?.totalOrgs ?? 0)}
hint={`${data?.activeOrgs ?? 0} активных, ${data?.archivedOrgs ?? 0} в архиве`} accent="indigo" /> hint={`${data?.activeOrgs ?? 0} активных, ${data?.archivedOrgs ?? 0} в архиве`} accent="indigo" />
<Kpi icon={CreditCard} label="Платящих клиентов" value="—"
hint="Скоро · после внедрения биллинга" accent="emerald" muted />
<Kpi icon={TrendingUp} label="MRR (₸ / мес)" value="—"
hint="Скоро · после внедрения биллинга" accent="violet" muted />
<Kpi icon={AlertTriangle} label="Должники" value="—"
hint="Скоро · после внедрения биллинга" accent="rose" muted />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 sm:gap-4">
<Kpi icon={Users} label="Пользователей" value={fmt.format(data?.totalUsers ?? 0)} <Kpi icon={Users} label="Пользователей" value={fmt.format(data?.totalUsers ?? 0)}
hint={`${data?.activeUsers ?? 0} активных`} accent="sky" /> hint={`${data?.activeUsers ?? 0} активных`} accent="sky" />
<Kpi icon={Package} label="Товаров (всего)" value={fmt.format(data?.totalProducts ?? 0)} accent="emerald" /> <Kpi icon={UserPlus} label="Регистраций за 30 дней" value={fmt.format(data?.registrationsLast30Days ?? 0)}
<Kpi icon={ShoppingCart} label="Приёмок за месяц" value={fmt.format(data?.totalSuppliesThisMonth ?? 0)} accent="amber" /> hint="Новые организации" accent="amber" />
</div> </div>
{/* Three side-by-side blocks */} {/* Three side-by-side blocks */}