ui(super-admin): hero-блок и KPI карточки на главной + три полезных секции

/super-admin переработан:
- Hero на indigo-градиенте: badge «Super Admin Console» + крупный H1
  «Системная консоль» + подзаголовок + env+build справа.
- 4 KPI карточки с цветным круглым фоном иконки (indigo/sky/
  emerald/amber), label uppercase tracking, значение крупно,
  hint мелко серым; hover-shadow.
- Три карточки в ряд (lg:3 cols) вместо дублирующих ссылок-CTA:
  «Здоровье системы» (заглушки /Activity для скорых проверок),
  «Активные организации» (топ-3 по объёму, ссылка «все»),
  «Свежие события» (6 последних audit-записей или empty state
  с Inbox-иконкой и текстом «Журнал пуст. Здесь появятся события»).
- Кнопка «Создать организацию» убрана с главной — оставлена
  только на /super-admin/organizations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 15:32:29 +05:00
parent 4d4a3a4786
commit 9d8fd2cb53

View file

@ -1,8 +1,10 @@
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 { Building2, Users, Package, ShoppingCart, FileText, Plus } from 'lucide-react' import {
Building2, Users, Package, ShoppingCart, FileClock,
CheckCircle2, AlertCircle, Activity, Inbox, ShieldCheck,
} from 'lucide-react'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader'
interface DashboardStats { interface DashboardStats {
totalOrgs: number; activeOrgs: number; archivedOrgs: number totalOrgs: number; activeOrgs: number; archivedOrgs: number
@ -17,23 +19,47 @@ interface AuditRow {
const fmt = new Intl.NumberFormat('ru') const fmt = new Intl.NumberFormat('ru')
function Kpi({ icon: Icon, label, value, hint }: { function Kpi({ icon: Icon, label, value, hint, accent = 'indigo' }: {
icon: React.ComponentType<{ className?: string }>; label: string; value: string | number; hint?: string icon: React.ComponentType<{ className?: string }>
label: string; value: string | number; hint?: string
accent?: 'indigo' | 'emerald' | 'amber' | 'sky'
}) { }) {
const accents: Record<string, string> = {
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',
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',
}
return ( return (
<div className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-4"> <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="flex items-start justify-between gap-3"> <div className="flex items-start gap-3">
<div> <div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${accents[accent]}`}>
<div className="text-xs text-slate-500">{label}</div> <Icon className="w-5 h-5" />
<div className="text-2xl font-bold mt-1">{value}</div> </div>
<div className="min-w-0">
<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>
{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>
<Icon className="w-6 h-6 text-slate-300" />
</div> </div>
</div> </div>
) )
} }
function HealthRow({ label, ok, hint }: { label: string; ok: boolean | 'unknown'; hint?: string }) {
return (
<li className="flex items-center justify-between py-1.5 text-sm">
<div className="flex items-center gap-2 min-w-0">
{ok === true && <CheckCircle2 className="w-4 h-4 text-emerald-500" />}
{ok === false && <AlertCircle className="w-4 h-4 text-red-500" />}
{ok === 'unknown' && <Activity className="w-4 h-4 text-slate-400" />}
<span className="truncate">{label}</span>
</div>
{hint && <span className="text-xs text-slate-400">{hint}</span>}
</li>
)
}
export function SuperAdminDashboardPage() { export function SuperAdminDashboardPage() {
const { data } = useQuery({ const { data } = useQuery({
queryKey: ['/api/super-admin/dashboard'], queryKey: ['/api/super-admin/dashboard'],
@ -43,64 +69,115 @@ export function SuperAdminDashboardPage() {
queryKey: ['/api/super-admin/audit-log', 'recent'], queryKey: ['/api/super-admin/audit-log', 'recent'],
queryFn: async () => (await api.get<{ items: AuditRow[] }>('/api/super-admin/audit-log?pageSize=10')).data.items, queryFn: async () => (await api.get<{ items: AuditRow[] }>('/api/super-admin/audit-log?pageSize=10')).data.items,
}) })
const orgsTop = useQuery({
queryKey: ['/api/super-admin/organizations', 'top'],
queryFn: async () => (await api.get<{ items: Array<{ id: string; name: string; productCount: number; employeeCount: number; lastLoginAt: string | null }> }>(
'/api/super-admin/organizations?pageSize=3&archived=false')).data.items,
})
return ( return (
<div className="h-full overflow-auto"> <div className="h-full overflow-auto">
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-5"> <div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-5">
<PageHeader {/* Hero */}
title="Супер-админ" <section className="relative rounded-xl overflow-hidden bg-gradient-to-br from-indigo-600 to-indigo-800 text-white p-6 sm:p-8">
description="Управление всеми организациями системы. Вы видите данные за пределами tenant-фильтра." <div className="flex items-start justify-between gap-4">
/> <div>
<div className="inline-flex items-center gap-1.5 text-[11px] uppercase tracking-wider bg-white/15 px-2 py-0.5 rounded-full mb-2">
<ShieldCheck className="w-3.5 h-3.5" /> Super Admin Console
</div>
<h1 className="text-2xl sm:text-3xl font-bold leading-tight">Системная консоль</h1>
<p className="text-sm text-indigo-100/85 mt-1.5 max-w-xl">
Управление всеми организациями, аудит действий, состояние системы.
Tenant-данные доступны через «Открыть организацию» в верхней панели.
</p>
</div>
<div className="hidden sm:flex flex-col items-end text-xs text-indigo-100/70">
<span className="font-mono">stage</span>
<span className="font-mono opacity-70">build {new Date().toISOString().slice(0, 10)}</span>
</div>
</div>
</section>
{/* KPI cards */}
<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} в архиве`} /> hint={`${data?.activeOrgs ?? 0} активных, ${data?.archivedOrgs ?? 0} в архиве`} accent="indigo" />
<Kpi icon={Users} label="Пользователей" value={fmt.format(data?.totalUsers ?? 0)} <Kpi icon={Users} label="Пользователей" value={fmt.format(data?.totalUsers ?? 0)}
hint={`${data?.activeUsers ?? 0} активных`} /> hint={`${data?.activeUsers ?? 0} активных`} accent="sky" />
<Kpi icon={Package} label="Товаров (всего)" value={fmt.format(data?.totalProducts ?? 0)} /> <Kpi icon={Package} label="Товаров (всего)" value={fmt.format(data?.totalProducts ?? 0)} accent="emerald" />
<Kpi icon={ShoppingCart} label="Приёмок за месяц" value={fmt.format(data?.totalSuppliesThisMonth ?? 0)} /> <Kpi icon={ShoppingCart} label="Приёмок за месяц" value={fmt.format(data?.totalSuppliesThisMonth ?? 0)} accent="amber" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<Link to="/super-admin/organizations" className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-4 hover:bg-slate-50 dark:hover:bg-slate-800/40">
<div className="flex items-center gap-3">
<Building2 className="w-5 h-5 text-[var(--color-brand)]" />
<div>
<div className="font-semibold">Организации</div>
<div className="text-xs text-slate-500">Создание, правка, архивирование</div>
</div>
</div>
</Link>
<Link to="/super-admin/audit-log" className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-4 hover:bg-slate-50 dark:hover:bg-slate-800/40">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-[var(--color-brand)]" />
<div>
<div className="font-semibold">Журнал действий</div>
<div className="text-xs text-slate-500">Аудит-лог супер-админа</div>
</div>
</div>
</Link>
</div>
<div className="flex items-center gap-3">
<Link to="/super-admin/organizations/new" className="inline-flex items-center gap-1.5 px-3 py-2 rounded-md bg-[var(--color-brand)] text-white text-sm font-medium hover:bg-[var(--color-brand-hover)]">
<Plus className="w-4 h-4" /> Создать организацию
</Link>
</div> </div>
{/* Three side-by-side blocks */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-3 sm:gap-4">
{/* Здоровье — пока заглушки, реальные проверки в следующей итерации */}
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5">
<h3 className="font-semibold mb-3 flex items-center gap-2">
<Activity className="w-4 h-4 text-emerald-600" /> Здоровье системы
</h3>
<ul className="divide-y divide-slate-100 dark:divide-slate-800">
<HealthRow label="API" ok={true} hint="200" />
<HealthRow label="База данных" ok={true} hint="connected" />
<HealthRow label="Telegram bridge" ok={'unknown'} hint="—" />
<HealthRow label="Последний бэкап" ok={'unknown'} hint="скоро" />
</ul>
</section>
{/* Топ организации по объёму */}
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5"> <section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<h3 className="font-semibold">Последние события</h3> <h3 className="font-semibold flex items-center gap-2">
<Link to="/super-admin/audit-log" className="text-xs text-[var(--color-brand)] hover:underline">Весь журнал </Link> <Building2 className="w-4 h-4 text-indigo-600" /> Активные организации
</h3>
<Link to="/super-admin/organizations" className="text-xs text-indigo-600 hover:underline">все </Link>
</div> </div>
{audit.data?.length === 0 ? ( {orgsTop.data?.length === 0 ? (
<div className="text-sm text-slate-400">Пока нет записей.</div> <div className="text-sm text-slate-400">Нет организаций.</div>
) : ( ) : (
<ul className="divide-y divide-slate-100 dark:divide-slate-800"> <ul className="divide-y divide-slate-100 dark:divide-slate-800">
{audit.data?.map((r) => ( {orgsTop.data?.map((o) => (
<li key={r.id} className="py-2 flex items-start gap-3 text-sm"> <li key={o.id} className="py-2">
<span className="text-xs text-slate-400 w-32 flex-shrink-0">{new Date(r.createdAt).toLocaleString('ru')}</span> <div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-mono text-slate-500 w-28 flex-shrink-0">{r.actionType}</span> <span className="font-medium truncate">{o.name}</span>
<span className="text-slate-700 dark:text-slate-300 truncate"> <span className="text-xs text-slate-400 flex-shrink-0">
{fmt.format(o.productCount)} товаров
</span>
</div>
<div className="text-xs text-slate-500">
{fmt.format(o.employeeCount)} сотр.
{o.lastLoginAt && <> · last login {new Date(o.lastLoginAt).toLocaleDateString('ru')}</>}
</div>
</li>
))}
</ul>
)}
</section>
{/* Свежие события */}
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold flex items-center gap-2">
<FileClock className="w-4 h-4 text-amber-600" /> Свежие события
</h3>
<Link to="/super-admin/audit-log" className="text-xs text-indigo-600 hover:underline">журнал </Link>
</div>
{audit.data?.length === 0 ? (
<div className="text-sm text-slate-400 flex flex-col items-center gap-2 py-6 text-center">
<Inbox className="w-8 h-8 text-slate-300" />
<span>Журнал действий пуст. Здесь появятся события: создание орг, входы как, архивация.</span>
</div>
) : (
<ul className="divide-y divide-slate-100 dark:divide-slate-800">
{audit.data?.slice(0, 6).map((r) => (
<li key={r.id} className="py-2 text-sm">
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-mono text-slate-500">{r.actionType}</span>
<span className="text-xs text-slate-400">{new Date(r.createdAt).toLocaleString('ru', { dateStyle: 'short', timeStyle: 'short' })}</span>
</div>
<div className="text-slate-700 dark:text-slate-300 truncate">
{r.organizationName && <span className="font-medium mr-1">«{r.organizationName}»</span>} {r.organizationName && <span className="font-medium mr-1">«{r.organizationName}»</span>}
<span className="text-slate-500">{r.description}</span> <span className="text-slate-500">{r.description}</span>
</span> </div>
</li> </li>
))} ))}
</ul> </ul>
@ -108,5 +185,6 @@ export function SuperAdminDashboardPage() {
</section> </section>
</div> </div>
</div> </div>
</div>
) )
} }