Compare commits
No commits in common. "4dafdc89959112cfcdc7cd884c39c051a265d6ef" and "17be1c83b29947134b93fac967f10f3df510544e" have entirely different histories.
4dafdc8995
...
17be1c83b2
|
|
@ -1,6 +1,5 @@
|
||||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useReadOnly } from '@/lib/useReadOnly'
|
|
||||||
|
|
||||||
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger'
|
||||||
type Size = 'sm' | 'md'
|
type Size = 'sm' | 'md'
|
||||||
|
|
@ -8,10 +7,6 @@ type Size = 'sm' | 'md'
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: Variant
|
variant?: Variant
|
||||||
size?: Size
|
size?: Size
|
||||||
/** Кнопка инициирует мутацию данных. В read-only режиме SuperAdmin'а
|
|
||||||
* (override без edit-mode) такие кнопки автоматически disabled с tooltip.
|
|
||||||
* Нав-кнопки/закрытие модалок/Cancel — оставлять без этого пропа. */
|
|
||||||
mutating?: boolean
|
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,20 +22,10 @@ const sizes: Record<Size, string> = {
|
||||||
md: 'px-3.5 py-1.5 text-sm',
|
md: 'px-3.5 py-1.5 text-sm',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Button({ variant = 'primary', size = 'md', mutating, className, children, disabled, title, ...rest }: ButtonProps) {
|
export function Button({ variant = 'primary', size = 'md', className, children, ...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 (
|
return (
|
||||||
<button
|
<button
|
||||||
{...rest}
|
{...rest}
|
||||||
disabled={disabled || blocked}
|
|
||||||
title={blocked ? ro.reason : title}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-1.5 rounded-md font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
|
'inline-flex items-center gap-1.5 rounded-md font-medium transition-colors disabled:opacity-60 disabled:cursor-not-allowed',
|
||||||
variants[variant],
|
variants[variant],
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,14 @@
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface Props { className?: string; variant?: 'light' | 'dark' }
|
export function Logo({ className }: { className?: string }) {
|
||||||
|
|
||||||
export function Logo({ className, variant = 'light' }: Props) {
|
|
||||||
const isDark = variant === 'dark'
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex flex-col leading-none select-none', className)}>
|
<div className={cn('flex flex-col leading-none select-none', className)}>
|
||||||
<span className={cn(
|
<span className="font-black text-slate-900 dark:text-slate-100 tracking-[0.08em] text-base">
|
||||||
'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
|
FOOD
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
className="font-black text-[11px] tracking-[0.24em] mt-0.5"
|
className="font-black text-[11px] tracking-[0.24em] mt-0.5"
|
||||||
style={{ color: isDark ? '#34D399' /* emerald-400 для контраста */ : 'var(--color-brand)' }}
|
style={{ color: 'var(--color-brand)' }}
|
||||||
>
|
>
|
||||||
MARKET
|
MARKET
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export function SuperAdminAsOrgBanner() {
|
||||||
<Lock className="w-3.5 h-3.5" /> Снять edit
|
<Lock className="w-3.5 h-3.5" /> Снять edit
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button onClick={() => setOrgOverride(null, { redirectTo: '/super-admin/organizations' })}
|
<button onClick={() => setOrgOverride(null)}
|
||||||
className="flex items-center gap-1 px-2 py-0.5 rounded bg-white/20 hover:bg-white/30">
|
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" /> Выйти
|
<X className="w-3.5 h-3.5" /> Выйти
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ export function SuperAdminLayout() {
|
||||||
<>
|
<>
|
||||||
<div className="px-5 py-4 border-b border-indigo-900/40">
|
<div className="px-5 py-4 border-b border-indigo-900/40">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Logo variant="dark" />
|
<Logo />
|
||||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-200 font-semibold">
|
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-indigo-500/20 text-indigo-200 font-semibold">
|
||||||
Super
|
Super
|
||||||
</span>
|
</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">
|
<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>
|
<button onClick={() => setDrawerOpen(true)}><Menu className="w-6 h-6" /></button>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Logo variant="dark" />
|
<Logo />
|
||||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-indigo-500/30 text-indigo-100">Super</span>
|
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-indigo-500/30 text-indigo-100">Super</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
@ -169,7 +169,7 @@ export function SuperAdminLayout() {
|
||||||
{orgs.data?.map((o) => (
|
{orgs.data?.map((o) => (
|
||||||
<button
|
<button
|
||||||
key={o.id}
|
key={o.id}
|
||||||
onClick={() => { setOrgPickerOpen(false); setOrgOverride({ id: o.id, name: o.name }, { redirectTo: '/dashboard' }) }}
|
onClick={() => { setOrgPickerOpen(false); setOrgOverride({ id: o.id, name: o.name }) }}
|
||||||
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-800 truncate"
|
className="w-full text-left px-3 py-2 text-sm hover:bg-slate-100 dark:hover:bg-slate-800 truncate"
|
||||||
>
|
>
|
||||||
{o.name}
|
{o.name}
|
||||||
|
|
|
||||||
|
|
@ -32,17 +32,12 @@ export function getOrgOverride(): OrgOverride | null {
|
||||||
return raw ? JSON.parse(raw) as OrgOverride : null
|
return raw ? JSON.parse(raw) as OrgOverride : null
|
||||||
} catch { return null }
|
} catch { return null }
|
||||||
}
|
}
|
||||||
export function setOrgOverride(value: OrgOverride | null, opts?: { redirectTo?: string }) {
|
export function setOrgOverride(value: OrgOverride | null) {
|
||||||
if (value) localStorage.setItem(ORG_OVERRIDE_KEY, JSON.stringify(value))
|
if (value) localStorage.setItem(ORG_OVERRIDE_KEY, JSON.stringify(value))
|
||||||
else { localStorage.removeItem(ORG_OVERRIDE_KEY); localStorage.removeItem(EDIT_MODE_KEY) }
|
else { localStorage.removeItem(ORG_OVERRIDE_KEY); localStorage.removeItem(EDIT_MODE_KEY) }
|
||||||
// Hard navigation чтобы (а) снести TanStack Query кэш, (б) гарантированно
|
// Силой обновляем все вкладки/страницы — кэш TanStack Query построен по
|
||||||
// выйти из текущего layout'а в нужный (override → tenant /dashboard,
|
// tenant'у, нужен hard reload чтобы снять старые данные.
|
||||||
// выход → /super-admin/organizations). Без redirectTo — просто reload
|
if (typeof window !== 'undefined') window.location.reload()
|
||||||
// на ту же страницу (старое поведение, для совместимости).
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
if (opts?.redirectTo) window.location.assign(opts.redirectTo)
|
|
||||||
else window.location.reload()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const EDIT_MODE_KEY = 'superAdminEditMode'
|
const EDIT_MODE_KEY = 'superAdminEditMode'
|
||||||
|
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
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: 'Только просмотр. Включите редактирование в баннере сверху, чтобы менять данные.',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
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, FileText, Plus } from 'lucide-react'
|
||||||
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
|
||||||
|
|
@ -19,47 +17,23 @@ 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 }: {
|
||||||
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'
|
|
||||||
}) {
|
}) {
|
||||||
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 transition hover:shadow-sm hover:border-slate-300 dark:hover:border-slate-700">
|
<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 gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${accents[accent]}`}>
|
<div>
|
||||||
<Icon className="w-5 h-5" />
|
<div className="text-xs text-slate-500">{label}</div>
|
||||||
</div>
|
<div className="text-2xl font-bold mt-1">{value}</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'],
|
||||||
|
|
@ -69,121 +43,69 @@ 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">
|
||||||
{/* Hero */}
|
<PageHeader
|
||||||
<section className="relative rounded-xl overflow-hidden bg-gradient-to-br from-indigo-600 to-indigo-800 text-white p-6 sm:p-8">
|
title="Супер-админ"
|
||||||
<div className="flex items-start justify-between gap-4">
|
description="Управление всеми организациями системы. Вы видите данные за пределами tenant-фильтра."
|
||||||
<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} в архиве`} accent="indigo" />
|
hint={`${data?.activeOrgs ?? 0} активных, ${data?.archivedOrgs ?? 0} в архиве`} />
|
||||||
<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} активных`} />
|
||||||
<Kpi icon={Package} label="Товаров (всего)" value={fmt.format(data?.totalProducts ?? 0)} accent="emerald" />
|
<Kpi icon={Package} label="Товаров (всего)" value={fmt.format(data?.totalProducts ?? 0)} />
|
||||||
<Kpi icon={ShoppingCart} label="Приёмок за месяц" value={fmt.format(data?.totalSuppliesThisMonth ?? 0)} accent="amber" />
|
<Kpi icon={ShoppingCart} label="Приёмок за месяц" value={fmt.format(data?.totalSuppliesThisMonth ?? 0)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||||
{/* Three side-by-side blocks */}
|
<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="grid grid-cols-1 lg:grid-cols-3 gap-3 sm:gap-4">
|
<div className="flex items-center gap-3">
|
||||||
{/* Здоровье — пока заглушки, реальные проверки в следующей итерации */}
|
<Building2 className="w-5 h-5 text-[var(--color-brand)]" />
|
||||||
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5">
|
<div>
|
||||||
<h3 className="font-semibold mb-3 flex items-center gap-2">
|
<div className="font-semibold">Организации</div>
|
||||||
<Activity className="w-4 h-4 text-emerald-600" /> Здоровье системы
|
<div className="text-xs text-slate-500">Создание, правка, архивирование</div>
|
||||||
</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 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>
|
|
||||||
{orgsTop.data?.length === 0 ? (
|
|
||||||
<div className="text-sm text-slate-400">Нет организаций.</div>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-slate-100 dark:divide-slate-800">
|
|
||||||
{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>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<ul className="divide-y divide-slate-100 dark:divide-slate-800">
|
</Link>
|
||||||
{audit.data?.slice(0, 6).map((r) => (
|
<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">
|
||||||
<li key={r.id} className="py-2 text-sm">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-baseline justify-between gap-2">
|
<FileText className="w-5 h-5 text-[var(--color-brand)]" />
|
||||||
<span className="text-xs font-mono text-slate-500">{r.actionType}</span>
|
<div>
|
||||||
<span className="text-xs text-slate-400">{new Date(r.createdAt).toLocaleString('ru', { dateStyle: 'short', timeStyle: 'short' })}</span>
|
<div className="font-semibold">Журнал действий</div>
|
||||||
</div>
|
<div className="text-xs text-slate-500">Аудит-лог супер-админа</div>
|
||||||
<div className="text-slate-700 dark:text-slate-300 truncate">
|
</div>
|
||||||
{r.organizationName && <span className="font-medium mr-1">«{r.organizationName}»</span>}
|
</div>
|
||||||
<span className="text-slate-500">{r.description}</span>
|
</Link>
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
{audit.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">
|
||||||
|
{r.organizationName && <span className="font-medium mr-1">«{r.organizationName}»</span>}
|
||||||
|
<span className="text-slate-500">{r.description}</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ export function SuperAdminOrganizationsPage() {
|
||||||
<div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}>
|
<div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}>
|
||||||
{!r.isArchived && (
|
{!r.isArchived && (
|
||||||
<button title="Открыть как… (read-only)"
|
<button title="Открыть как… (read-only)"
|
||||||
onClick={() => setOrgOverride({ id: r.id, name: r.name }, { redirectTo: '/dashboard' })}
|
onClick={() => setOrgOverride({ id: r.id, name: r.name })}
|
||||||
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded">
|
className="p-1.5 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded">
|
||||||
<LogIn className="w-4 h-4" />
|
<LogIn className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue