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:
parent
651038f683
commit
9d89a2aeee
|
|
@ -1,8 +1,10 @@
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
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 { PageHeader } from '@/components/PageHeader'
|
||||
|
||||
interface DashboardStats {
|
||||
totalOrgs: number; activeOrgs: number; archivedOrgs: number
|
||||
|
|
@ -17,23 +19,47 @@ interface AuditRow {
|
|||
|
||||
const fmt = new Intl.NumberFormat('ru')
|
||||
|
||||
function Kpi({ icon: Icon, label, value, hint }: {
|
||||
icon: React.ComponentType<{ className?: string }>; label: string; value: string | number; hint?: string
|
||||
function Kpi({ icon: Icon, label, value, hint, accent = 'indigo' }: {
|
||||
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 (
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs text-slate-500">{label}</div>
|
||||
<div className="text-2xl font-bold mt-1">{value}</div>
|
||||
<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 gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${accents[accent]}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</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>}
|
||||
</div>
|
||||
<Icon className="w-6 h-6 text-slate-300" />
|
||||
</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() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['/api/super-admin/dashboard'],
|
||||
|
|
@ -43,64 +69,115 @@ export function SuperAdminDashboardPage() {
|
|||
queryKey: ['/api/super-admin/audit-log', 'recent'],
|
||||
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 (
|
||||
<div className="h-full overflow-auto">
|
||||
<div className="max-w-6xl mx-auto p-4 sm:p-6 space-y-5">
|
||||
<PageHeader
|
||||
title="Супер-админ"
|
||||
description="Управление всеми организациями системы. Вы видите данные за пределами tenant-фильтра."
|
||||
/>
|
||||
{/* Hero */}
|
||||
<section className="relative rounded-xl overflow-hidden bg-gradient-to-br from-indigo-600 to-indigo-800 text-white p-6 sm:p-8">
|
||||
<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">
|
||||
<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)}
|
||||
hint={`${data?.activeUsers ?? 0} активных`} />
|
||||
<Kpi icon={Package} label="Товаров (всего)" value={fmt.format(data?.totalProducts ?? 0)} />
|
||||
<Kpi icon={ShoppingCart} label="Приёмок за месяц" value={fmt.format(data?.totalSuppliesThisMonth ?? 0)} />
|
||||
</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>
|
||||
hint={`${data?.activeUsers ?? 0} активных`} accent="sky" />
|
||||
<Kpi icon={Package} label="Товаров (всего)" value={fmt.format(data?.totalProducts ?? 0)} accent="emerald" />
|
||||
<Kpi icon={ShoppingCart} label="Приёмок за месяц" value={fmt.format(data?.totalSuppliesThisMonth ?? 0)} accent="amber" />
|
||||
</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">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold">Последние события</h3>
|
||||
<Link to="/super-admin/audit-log" className="text-xs text-[var(--color-brand)] hover:underline">Весь журнал →</Link>
|
||||
<h3 className="font-semibold flex items-center gap-2">
|
||||
<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>
|
||||
{audit.data?.length === 0 ? (
|
||||
<div className="text-sm text-slate-400">Пока нет записей.</div>
|
||||
{orgsTop.data?.length === 0 ? (
|
||||
<div className="text-sm text-slate-400">Нет организаций.</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-slate-100 dark:divide-slate-800">
|
||||
{audit.data?.map((r) => (
|
||||
<li key={r.id} className="py-2 flex items-start gap-3 text-sm">
|
||||
<span className="text-xs text-slate-400 w-32 flex-shrink-0">{new Date(r.createdAt).toLocaleString('ru')}</span>
|
||||
<span className="text-xs font-mono text-slate-500 w-28 flex-shrink-0">{r.actionType}</span>
|
||||
<span className="text-slate-700 dark:text-slate-300 truncate">
|
||||
{orgsTop.data?.map((o) => (
|
||||
<li key={o.id} className="py-2">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium truncate">{o.name}</span>
|
||||
<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>}
|
||||
<span className="text-slate-500">{r.description}</span>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
@ -108,5 +185,6 @@ export function SuperAdminDashboardPage() {
|
|||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue