Compare commits

...

3 commits

Author SHA1 Message Date
nns 4dafdc8995 feat(super-admin): рабочий quick-switch + UI-блокировка мутаций в read-only
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m6s
CI / Web (React + Vite) (push) Successful in 39s
Docker Web / Build + push Web (push) Successful in 31s
Docker Web / Deploy Web on stage (push) Successful in 11s
БАГ: dropdown «Открыть организацию» в topbar системной консоли вёл
сначала на reload текущего /super-admin, а оттуда внутренний редирект
конкурировал с TenantRouteGuard'ом, и юзер видел alert «Откройте
через "Открыть как…"». Фикс — setOrgOverride получил опциональный
{ redirectTo }, делает window.location.assign(target) вместо reload.
Точки вызова обновлены:
- dropdown в SuperAdminLayout topbar → redirectTo='/dashboard'
- кнопка «Открыть как» в строке таблицы орг → redirectTo='/dashboard'
- кнопка «Выйти из режима» в баннере → redirectTo='/super-admin/organizations'

Хук useReadOnly() централизует «override активен И edit-mode не
включён» в один { readOnly, reason }. Button по умолчанию считает
variant='primary' и variant='danger' мутирующими (опт-аут через
mutating={false} для редких primary-без-мутаций) — в read-only они
автоматически disabled с tooltip «Только просмотр. Включите
редактирование в баннере…». Variant='secondary' и 'ghost' не
блокируются — Cancel/Назад/Закрыть остаются кликабельны. Серверный
403 (ReadonlyOverrideMiddleware) остаётся как safety-net.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:29 +05:00
nns 9d89a2aeee 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>
2026-04-26 15:32:29 +05:00
nns 651038f683 ui(super-admin): читаемый логотип на тёмном sidebar
<Logo> получил пропс variant="dark": в нём «FOOD» рендерится белым
(slate-50) вместо чёрного, «MARKET» — emerald-400 (вместо var brand)
для контраста на indigo-950 фоне SuperAdminLayout. SuperAdminLayout
прокидывает variant="dark" в обоих местах (desktop sidebar + mobile
header). Светлый вариант остался по умолчанию для tenant AppLayout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 15:32:29 +05:00
8 changed files with 197 additions and 76 deletions

View file

@ -1,5 +1,6 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { useReadOnly } from '@/lib/useReadOnly'
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
type Size = 'sm' | 'md'
@ -7,6 +8,10 @@ type Size = 'sm' | 'md'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: Variant
size?: Size
/** Кнопка инициирует мутацию данных. В read-only режиме SuperAdmin'а
* (override без edit-mode) такие кнопки автоматически disabled с tooltip.
* Нав-кнопки/закрытие модалок/Cancel оставлять без этого пропа. */
mutating?: boolean
children: ReactNode
}
@ -22,10 +27,20 @@ const sizes: Record<Size, string> = {
md: 'px-3.5 py-1.5 text-sm',
}
export function Button({ variant = 'primary', size = 'md', className, children, ...rest }: ButtonProps) {
export function Button({ variant = 'primary', size = 'md', mutating, className, children, disabled, title, ...rest }: ButtonProps) {
const ro = useReadOnly()
// Variant primary/danger по умолчанию считаем мутирующими (Добавить/
// Сохранить/Удалить/Создать почти всегда primary либо danger). Secondary/
// ghost — нав-кнопки и Cancel — не блокируются. Явный mutating={false}
// нужен только в редких случаях когда primary не делает мутации (например
// «Применить фильтр», «Войти в режим…»).
const isMutating = mutating ?? (variant === 'primary' || variant === 'danger')
const blocked = isMutating && ro.readOnly
return (
<button
{...rest}
disabled={disabled || blocked}
title={blocked ? ro.reason : title}
className={cn(
'inline-flex items-center gap-1.5 rounded-md font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
variants[variant],

View file

@ -1,14 +1,22 @@
import { cn } from '@/lib/utils'
export function Logo({ className }: { className?: string }) {
interface Props { className?: string; variant?: 'light' | 'dark' }
export function Logo({ className, variant = 'light' }: Props) {
const isDark = variant === 'dark'
return (
<div className={cn('flex flex-col leading-none select-none', className)}>
<span className="font-black text-slate-900 dark:text-slate-100 tracking-[0.08em] text-base">
<span className={cn(
'font-black tracking-[0.08em] text-base',
// На тёмном sidebar (indigo-950) чёрный «FOOD» сливался с фоном —
// в dark-варианте берём slate-50 для контраста.
isDark ? 'text-slate-50' : 'text-slate-900 dark:text-slate-100',
)}>
FOOD
</span>
<span
className="font-black text-[11px] tracking-[0.24em] mt-0.5"
style={{ color: 'var(--color-brand)' }}
style={{ color: isDark ? '#34D399' /* emerald-400 для контраста */ : 'var(--color-brand)' }}
>
MARKET
</span>

View file

@ -41,7 +41,7 @@ export function SuperAdminAsOrgBanner() {
<Lock className="w-3.5 h-3.5" /> Снять edit
</button>
)}
<button onClick={() => setOrgOverride(null)}
<button onClick={() => setOrgOverride(null, { redirectTo: '/super-admin/organizations' })}
className="flex items-center gap-1 px-2 py-0.5 rounded bg-white/20 hover:bg-white/30">
<X className="w-3.5 h-3.5" /> Выйти
</button>

View file

@ -69,7 +69,7 @@ export function SuperAdminLayout() {
<>
<div className="px-5 py-4 border-b border-indigo-900/40">
<div className="flex items-center justify-between">
<Logo />
<Logo variant="dark" />
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-200 font-semibold">
Super
</span>
@ -128,7 +128,7 @@ export function SuperAdminLayout() {
<header className="md:hidden h-12 flex items-center gap-3 px-4 border-b border-slate-200 dark:border-slate-800 bg-indigo-950 text-white flex-shrink-0">
<button onClick={() => setDrawerOpen(true)}><Menu className="w-6 h-6" /></button>
<div className="flex items-center gap-2">
<Logo />
<Logo variant="dark" />
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-indigo-500/30 text-indigo-100">Super</span>
</div>
</header>
@ -169,7 +169,7 @@ export function SuperAdminLayout() {
{orgs.data?.map((o) => (
<button
key={o.id}
onClick={() => { setOrgPickerOpen(false); setOrgOverride({ id: o.id, name: o.name }) }}
onClick={() => { setOrgPickerOpen(false); setOrgOverride({ id: o.id, name: o.name }, { redirectTo: '/dashboard' }) }}
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-800 truncate"
>
{o.name}

View file

@ -32,12 +32,17 @@ export function getOrgOverride(): OrgOverride | null {
return raw ? JSON.parse(raw) as OrgOverride : null
} catch { return null }
}
export function setOrgOverride(value: OrgOverride | null) {
export function setOrgOverride(value: OrgOverride | null, opts?: { redirectTo?: string }) {
if (value) localStorage.setItem(ORG_OVERRIDE_KEY, JSON.stringify(value))
else { localStorage.removeItem(ORG_OVERRIDE_KEY); localStorage.removeItem(EDIT_MODE_KEY) }
// Силой обновляем все вкладки/страницы — кэш TanStack Query построен по
// tenant'у, нужен hard reload чтобы снять старые данные.
if (typeof window !== 'undefined') window.location.reload()
// Hard navigation чтобы (а) снести TanStack Query кэш, (б) гарантированно
// выйти из текущего layout'а в нужный (override → tenant /dashboard,
// выход → /super-admin/organizations). Без redirectTo — просто reload
// на ту же страницу (старое поведение, для совместимости).
if (typeof window !== 'undefined') {
if (opts?.redirectTo) window.location.assign(opts.redirectTo)
else window.location.reload()
}
}
const EDIT_MODE_KEY = 'superAdminEditMode'

View file

@ -0,0 +1,15 @@
import { getOrgOverride, getEditMode } from '@/lib/api'
/** SuperAdmin зашёл в режим «открыто как» и edit-mode НЕ включён
* любые мутации в UI должны быть отключены. Возвращает { readOnly, reason }
* чтобы кнопки могли показать tooltip и причину. */
export function useReadOnly(): { readOnly: boolean; reason: string } {
const ov = getOrgOverride()
if (!ov) return { readOnly: false, reason: '' }
const edit = getEditMode()
if (edit && edit.expiresAt > Date.now()) return { readOnly: false, reason: '' }
return {
readOnly: true,
reason: 'Только просмотр. Включите редактирование в баннере сверху, чтобы менять данные.',
}
}

View file

@ -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>
)
}

View file

@ -100,7 +100,7 @@ export function SuperAdminOrganizationsPage() {
<div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}>
{!r.isArchived && (
<button title="Открыть как… (read-only)"
onClick={() => setOrgOverride({ id: r.id, name: r.name })}
onClick={() => setOrgOverride({ id: r.id, name: r.name }, { redirectTo: '/dashboard' })}
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded">
<LogIn className="w-4 h-4" />
</button>