feat(web): super-admin section + setup wizard + auto-redirect
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
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

Раздел /super-admin/* доступный только пользователю с ролью SuperAdmin.
В сайдбаре отдельная группа «Супер-админ» появляется только если
me.roles содержит SuperAdmin (UI-guard, серверная авторизация
параллельно проверяется через [Authorize(Roles=\"SuperAdmin\")]).

Страницы:
- /super-admin            — KPI-дашборд (orgs/users/products/supplies)
- /super-admin/organizations — таблица всех org с фильтром Активные/
  Архив/Все, поиском по Name/BIN. Действия в строке: Архивировать
  (модалка с вводом названия), Восстановить, Удалить навсегда (только
  если в архиве >30 дней + повторное подтверждение).
- /super-admin/organizations/new — двух-секционная форма создания
  (реквизиты + первый админ); в response получаем generatedPassword
  и показываем в одноразовой модалке с copy-кнопками.
- /super-admin/audit-log  — таблица журнала с экспортом в CSV.
- /super-admin/setup      — 3-шаговый wizard (welcome → org → admin →
  done с временным паролем). Авто-редирект на этот URL для SuperAdmin
  если /api/super-admin/setup-status возвращает needsSetup=true (в
  системе ноль организаций) — нельзя пропустить пока не создадут.

useMe()/useIsSuperAdmin() хуки в lib/useMe.ts — единая точка чтения
текущего юзера и его ролей для UI-гейтов.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 12:59:07 +05:00
parent 9482eea050
commit 94dbb5b235
8 changed files with 683 additions and 3 deletions

View file

@ -3,6 +3,11 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { LoginPage } from '@/pages/LoginPage' import { LoginPage } from '@/pages/LoginPage'
import { DashboardPage } from '@/pages/DashboardPage' import { DashboardPage } from '@/pages/DashboardPage'
import { OnboardingPage } from '@/pages/OnboardingPage' import { OnboardingPage } from '@/pages/OnboardingPage'
import { SuperAdminDashboardPage } from '@/pages/SuperAdminDashboardPage'
import { SuperAdminOrganizationsPage } from '@/pages/SuperAdminOrganizationsPage'
import { SuperAdminOrgCreatePage } from '@/pages/SuperAdminOrgCreatePage'
import { SuperAdminAuditLogPage } from '@/pages/SuperAdminAuditLogPage'
import { SuperAdminSetupPage } from '@/pages/SuperAdminSetupPage'
import { CountriesPage } from '@/pages/CountriesPage' import { CountriesPage } from '@/pages/CountriesPage'
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage' import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage' import { PriceTypesPage } from '@/pages/PriceTypesPage'
@ -66,6 +71,11 @@ export default function App() {
<Route path="/settings/organization" element={<OrganizationSettingsPage />} /> <Route path="/settings/organization" element={<OrganizationSettingsPage />} />
<Route path="/settings/employees" element={<EmployeesPage />} /> <Route path="/settings/employees" element={<EmployeesPage />} />
<Route path="/settings/employee-roles" element={<EmployeeRolesPage />} /> <Route path="/settings/employee-roles" element={<EmployeeRolesPage />} />
<Route path="/super-admin" element={<SuperAdminDashboardPage />} />
<Route path="/super-admin/setup" element={<SuperAdminSetupPage />} />
<Route path="/super-admin/organizations" element={<SuperAdminOrganizationsPage />} />
<Route path="/super-admin/organizations/new" element={<SuperAdminOrgCreatePage />} />
<Route path="/super-admin/audit-log" element={<SuperAdminAuditLogPage />} />
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View file

@ -6,7 +6,7 @@ import { logout } from '@/lib/auth'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { import {
LayoutDashboard, Package, FolderTree, Ruler, Tag, LayoutDashboard, Package, FolderTree, Ruler, Tag,
Users, Warehouse, Store as StoreIcon, Globe, LogOut, Download, UserCog, Shield, Users, Warehouse, Store as StoreIcon, Globe, LogOut, Download, UserCog, Shield, ShieldCheck, Building, FileClock,
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
@ -22,7 +22,7 @@ interface MeResponse {
type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean } type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean }
type NavSection = { group: string; items: NavItem[] } type NavSection = { group: string; items: NavItem[] }
function buildNav(): NavSection[] { function buildNav(isSuperAdmin: boolean): NavSection[] {
const catalog: NavItem[] = [ const catalog: NavItem[] = [
{ to: '/catalog/products', icon: Package, label: 'Товары' }, { to: '/catalog/products', icon: Package, label: 'Товары' },
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' }, { to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' },
@ -61,6 +61,14 @@ function buildNav(): NavSection[] {
{ to: '/settings/employees', icon: UserCog, label: 'Сотрудники' }, { to: '/settings/employees', icon: UserCog, label: 'Сотрудники' },
{ to: '/settings/employee-roles', icon: Shield, label: 'Роли' }, { to: '/settings/employee-roles', icon: Shield, label: 'Роли' },
]}, ]},
...(isSuperAdmin ? [{
group: 'Супер-админ',
items: [
{ to: '/super-admin', icon: ShieldCheck, label: 'Консоль', end: true },
{ to: '/super-admin/organizations', icon: Building, label: 'Организации' },
{ to: '/super-admin/audit-log', icon: FileClock, label: 'Журнал' },
],
}] : []),
] ]
} }
@ -71,7 +79,18 @@ export function AppLayout() {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}) })
const nav = buildNav() const isSuperAdmin = !!me?.roles?.includes('SuperAdmin')
const nav = buildNav(isSuperAdmin)
// Если SuperAdmin зашёл, а в системе ноль организаций — выкидываем
// на setup wizard (нельзя пропустить, пока не создадут первую орг).
const location2 = useLocation()
useEffect(() => {
if (!isSuperAdmin) return
if (location2.pathname.startsWith('/super-admin/setup')) return
api.get<{ needsSetup: boolean }>('/api/super-admin/setup-status')
.then((r) => { if (r.data.needsSetup) window.location.replace('/super-admin/setup') })
.catch(() => {})
}, [isSuperAdmin, location2.pathname])
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const location = useLocation() const location = useLocation()

View file

@ -0,0 +1,25 @@
import { useQuery } from '@tanstack/react-query'
import { api } from '@/lib/api'
export interface MeResponse {
sub: string
name: string
email: string
roles: string[]
orgId: string
}
/** Текущий залогиненный юзер с ролями. Используется для гейтов
* SuperAdmin-меню и проверок прав в UI (не вместо серверной авторизации). */
export function useMe() {
return useQuery({
queryKey: ['me'],
queryFn: async () => (await api.get<MeResponse>('/api/me')).data,
staleTime: 5 * 60 * 1000,
})
}
export function useIsSuperAdmin(): boolean {
const me = useMe()
return !!me.data?.roles?.includes('SuperAdmin')
}

View file

@ -0,0 +1,57 @@
import { useCatalogList } from '@/lib/useCatalog'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
interface AuditRow {
id: string; createdAt: string; superAdminUserId: string
actionType: string; organizationId: string | null; organizationName: string | null
entityType: string | null; entityId: string | null
description: string | null; reason: string | null; ipAddress: string
}
export function SuperAdminAuditLogPage() {
const list = useCatalogList<AuditRow>('/api/super-admin/audit-log')
const exportCsv = () => {
const rows = list.data?.items ?? []
const header = 'Дата;Действие;Организация;Описание;Причина;IP\n'
const csv = header + rows.map(r =>
[r.createdAt, r.actionType, r.organizationName ?? '', r.description ?? '', r.reason ?? '', r.ipAddress]
.map(s => `"${String(s).replace(/"/g, '""')}"`).join(';')
).join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = `audit-log-${new Date().toISOString().slice(0, 10)}.csv`
a.click(); URL.revokeObjectURL(url)
}
return (
<ListPageShell
title="Журнал действий супер-админа"
description="Все мутации SuperAdmin'а записываются автоматически с IP и причиной."
actions={<>
<SearchBar value={list.search} onChange={list.setSearch} />
<button onClick={exportCsv} className="px-3 py-1.5 rounded-md text-sm border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-800">
Экспорт CSV
</button>
</>}
footer={list.data && list.data.total > 0 && (
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
)}>
<DataTable
rows={list.data?.items ?? []}
isLoading={list.isLoading}
rowKey={(r) => r.id}
columns={[
{ header: 'Дата', width: '160px', cell: (r) => new Date(r.createdAt).toLocaleString('ru') },
{ header: 'Действие', width: '140px', cell: (r) => <span className="font-mono text-xs">{r.actionType}</span> },
{ header: 'Организация', cell: (r) => r.organizationName ?? '—' },
{ header: 'Описание', cell: (r) => r.description ?? '—' },
{ header: 'Причина', cell: (r) => r.reason ?? '—' },
{ header: 'IP', width: '140px', cell: (r) => <span className="font-mono text-xs text-slate-500">{r.ipAddress}</span> },
]}
/>
</ListPageShell>
)
}

View file

@ -0,0 +1,80 @@
import { useQuery } from '@tanstack/react-query'
import { Link } from 'react-router-dom'
import { Building2, Users, Package, ShoppingCart, FileText, Plus } from 'lucide-react'
import { api } from '@/lib/api'
import { PageHeader } from '@/components/PageHeader'
interface DashboardStats {
totalOrgs: number; activeOrgs: number; archivedOrgs: number
totalUsers: number; activeUsers: number
totalProducts: number; totalSuppliesThisMonth: number
}
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
}) {
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>
{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>
)
}
export function SuperAdminDashboardPage() {
const { data } = useQuery({
queryKey: ['/api/super-admin/dashboard'],
queryFn: async () => (await api.get<DashboardStats>('/api/super-admin/dashboard')).data,
})
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-фильтра."
/>
<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} в архиве`} />
<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>
<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>
</div>
)
}

View file

@ -0,0 +1,142 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Save, Copy } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput, Select } from '@/components/Field'
import { useCountries, useCurrencies } from '@/lib/useLookups'
import { PageHeader } from '@/components/PageHeader'
export function SuperAdminOrgCreatePage() {
const navigate = useNavigate()
const countries = useCountries()
const currencies = useCurrencies()
const [name, setName] = useState('')
const [countryCode, setCountryCode] = useState('KZ')
const [bin, setBin] = useState('')
const [address, setAddress] = useState('')
const [phone, setPhone] = useState('')
const [email, setEmail] = useState('')
const [defaultCurrencyId, setDefaultCurrencyId] = useState('')
const [adminLast, setAdminLast] = useState('')
const [adminFirst, setAdminFirst] = useState('')
const [adminEmail, setAdminEmail] = useState('')
const [adminPosition, setAdminPosition] = useState('Директор')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState<{ email: string; password: string } | null>(null)
const onCountryChange = (cc: string) => {
setCountryCode(cc)
const c = countries.data?.find((x) => x.code === cc)
if (c?.defaultCurrencyId) setDefaultCurrencyId(c.defaultCurrencyId)
}
const submit = async () => {
setError(null); setBusy(true)
try {
const res = await api.post<{ adminEmail: string; adminTempPassword: string; organization: { id: string } }>(
'/api/super-admin/organizations',
{
org: { name, countryCode, bin: bin || null, address: address || null,
phone: phone || null, email: email || null,
defaultCurrencyId: defaultCurrencyId || null, accountOwnerUserId: null },
adminLastName: adminLast, adminFirstName: adminFirst,
adminEmail, adminPosition: adminPosition || null,
})
setDone({ email: res.data.adminEmail, password: res.data.adminTempPassword })
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? (e as Error).message
setError(msg)
} finally { setBusy(false) }
}
const canSubmit = name.trim() && countryCode && adminLast.trim() && adminFirst.trim() && adminEmail.trim()
return (
<div className="h-full overflow-auto">
<div className="max-w-2xl mx-auto p-4 sm:p-6 space-y-5">
<PageHeader title="Новая организация" description="Создаётся вместе с Администратором (AppUser + Employee запись)." />
{error && <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>}
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="text-sm font-semibold">Реквизиты организации</h3>
<Field label="Название *">
<TextInput value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Страна *">
<Select value={countryCode} onChange={(e) => onCountryChange(e.target.value)}>
{countries.data?.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
</Select>
</Field>
<Field label="Валюта по умолчанию *">
<Select value={defaultCurrencyId} onChange={(e) => setDefaultCurrencyId(e.target.value)}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code} ({c.symbol})</option>)}
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="БИН/ИНН"><TextInput value={bin} onChange={(e) => setBin(e.target.value)} /></Field>
<Field label="Телефон"><TextInput value={phone} onChange={(e) => setPhone(e.target.value)} /></Field>
</div>
<Field label="Адрес"><TextInput value={address} onChange={(e) => setAddress(e.target.value)} /></Field>
<Field label="Email организации"><TextInput type="email" value={email} onChange={(e) => setEmail(e.target.value)} /></Field>
</section>
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<h3 className="text-sm font-semibold">Главный администратор</h3>
<div className="grid grid-cols-2 gap-3">
<Field label="Фамилия *"><TextInput value={adminLast} onChange={(e) => setAdminLast(e.target.value)} /></Field>
<Field label="Имя *"><TextInput value={adminFirst} onChange={(e) => setAdminFirst(e.target.value)} /></Field>
</div>
<Field label="Email (логин) *">
<TextInput type="email" value={adminEmail} onChange={(e) => setAdminEmail(e.target.value)} />
</Field>
<Field label="Должность">
<TextInput value={adminPosition} onChange={(e) => setAdminPosition(e.target.value)} />
</Field>
<p className="text-xs text-slate-500">
Будет сгенерирован временный пароль и показан один раз передайте его сотруднику.
</p>
</section>
<div className="flex gap-3">
<Button variant="secondary" onClick={() => navigate('/super-admin/organizations')}>Отмена</Button>
<Button onClick={submit} disabled={!canSubmit || busy}>
<Save className="w-4 h-4" /> {busy ? 'Создаю…' : 'Создать организацию'}
</Button>
</div>
</div>
<Modal open={!!done} onClose={() => { setDone(null); navigate('/super-admin/organizations') }}
title="Организация создана"
footer={<Button onClick={() => { setDone(null); navigate('/super-admin/organizations') }}>Готово</Button>}>
{done && (
<div className="space-y-3">
<p className="text-sm text-slate-600 dark:text-slate-400">
Передайте администратору данные для входа. Это окно показывается один раз.
</p>
<Field label="Логин (email)">
<div className="flex gap-2">
<TextInput value={done.email} readOnly />
<Button variant="secondary" size="sm" onClick={() => navigator.clipboard?.writeText(done.email)}>
<Copy className="w-4 h-4" />
</Button>
</div>
</Field>
<Field label="Временный пароль">
<div className="flex gap-2">
<TextInput value={done.password} readOnly className="font-mono" />
<Button variant="secondary" size="sm" onClick={() => navigator.clipboard?.writeText(done.password)}>
<Copy className="w-4 h-4" />
</Button>
</div>
</Field>
</div>
)}
</Modal>
</div>
)
}

View file

@ -0,0 +1,165 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { Plus, Archive, RotateCcw, Trash2, Eye } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal'
import { Field, TextInput } from '@/components/Field'
import { useCatalogList } from '@/lib/useCatalog'
import { api } from '@/lib/api'
const URL = '/api/super-admin/organizations'
interface OrgRow {
id: string; name: string; countryCode: string
isActive: boolean; isArchived: boolean; archivedAt: string | null
createdAt: string; employeeCount: number; productCount: number; lastLoginAt: string | null
}
export function SuperAdminOrganizationsPage() {
const navigate = useNavigate()
const [archived, setArchived] = useState<boolean | null>(false)
const list = useCatalogList<OrgRow>(URL, { archived: archived ?? undefined })
const [archiveOf, setArchiveOf] = useState<OrgRow | null>(null)
const [deleteOf, setDeleteOf] = useState<OrgRow | null>(null)
const [confirmName, setConfirmName] = useState('')
const archive = async () => {
if (!archiveOf || confirmName !== archiveOf.name) return
await api.post(`${URL}/${archiveOf.id}/archive`, { confirmationName: confirmName })
setArchiveOf(null); setConfirmName('')
list.refetch()
}
const restore = async (id: string) => {
await api.post(`${URL}/${id}/restore`)
list.refetch()
}
const hardDelete = async () => {
if (!deleteOf || confirmName !== deleteOf.name) return
await api.delete(`${URL}/${deleteOf.id}`, { data: { confirmationName: confirmName } })
setDeleteOf(null); setConfirmName('')
list.refetch()
}
const isOver30Days = (iso: string | null) => {
if (!iso) return false
return new Date(iso).getTime() < Date.now() - 30 * 24 * 60 * 60 * 1000
}
return (
<>
<ListPageShell
title="Организации"
description="Управление всеми организациями системы. Действия логируются в журнал."
actions={
<>
<SearchBar value={list.search} onChange={list.setSearch} />
<select value={archived === null ? 'all' : String(archived)}
onChange={(e) => setArchived(e.target.value === 'all' ? null : e.target.value === 'true')}
className="h-9 px-2 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 text-sm">
<option value="false">Активные</option>
<option value="true">Архив</option>
<option value="all">Все</option>
</select>
<Button onClick={() => navigate('/super-admin/organizations/new')}>
<Plus className="w-4 h-4" /> Создать
</Button>
</>
}
footer={list.data && list.data.total > 0 && (
<Pagination page={list.page} pageSize={list.data.pageSize} total={list.data.total} onPageChange={list.setPage} />
)}
>
<DataTable
rows={list.data?.items ?? []}
isLoading={list.isLoading}
rowKey={(r) => r.id}
sortKey={list.sortKey}
sortOrder={list.sortOrder}
onSortChange={list.setSort}
onRowClick={(r) => navigate(`/super-admin/organizations/${r.id}`)}
columns={[
{ header: 'Название', cell: (r) => (
<div>
<div className="font-medium">{r.name}</div>
<div className="text-xs text-slate-400">{r.countryCode}</div>
</div>
)},
{ header: 'Создана', width: '140px', cell: (r) => new Date(r.createdAt).toLocaleDateString('ru') },
{ header: 'Сотрудники', width: '110px', className: 'text-right', cell: (r) => r.employeeCount },
{ header: 'Товары', width: '110px', className: 'text-right', cell: (r) => r.productCount },
{ header: 'Last login', width: '140px', cell: (r) => r.lastLoginAt ? new Date(r.lastLoginAt).toLocaleDateString('ru') : '—' },
{ header: 'Статус', width: '120px', cell: (r) => r.isArchived
? <span className="text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700">Архив</span>
: <span className="text-xs text-emerald-600">Активна</span> },
{ header: '', width: '160px', cell: (r) => (
<div className="flex gap-1.5" onClick={(e) => e.stopPropagation()}>
<button title="Просмотр" disabled className="p-1.5 text-slate-300 cursor-not-allowed"><Eye className="w-4 h-4" /></button>
{!r.isArchived ? (
<button title="Архивировать" onClick={() => { setArchiveOf(r); setConfirmName('') }}
className="p-1.5 text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-900/20 rounded">
<Archive className="w-4 h-4" />
</button>
) : (
<>
<button title="Восстановить" onClick={() => restore(r.id)}
className="p-1.5 text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20 rounded">
<RotateCcw className="w-4 h-4" />
</button>
<button title={isOver30Days(r.archivedAt) ? 'Удалить навсегда' : 'Удаление доступно через 30 дней архива'}
disabled={!isOver30Days(r.archivedAt)}
onClick={() => isOver30Days(r.archivedAt) && (setDeleteOf(r), setConfirmName(''))}
className="p-1.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-30 disabled:cursor-not-allowed">
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
)},
]}
/>
</ListPageShell>
<Modal open={!!archiveOf} onClose={() => setArchiveOf(null)}
title="Архивировать организацию"
footer={<>
<Button variant="secondary" onClick={() => setArchiveOf(null)}>Отмена</Button>
<Button variant="danger" onClick={archive} disabled={confirmName !== archiveOf?.name}>Архивировать</Button>
</>}>
{archiveOf && (
<div className="space-y-3">
<p className="text-sm text-slate-600 dark:text-slate-400">
Организация <strong>{archiveOf.name}</strong> станет недоступной пользователям, но
данные сохранятся. Удалить навсегда можно через 30 дней.
</p>
<Field label={`Введи название «${archiveOf.name}» для подтверждения`}>
<TextInput value={confirmName} onChange={(e) => setConfirmName(e.target.value)} />
</Field>
</div>
)}
</Modal>
<Modal open={!!deleteOf} onClose={() => setDeleteOf(null)}
title="Удалить организацию навсегда"
footer={<>
<Button variant="secondary" onClick={() => setDeleteOf(null)}>Отмена</Button>
<Button variant="danger" onClick={hardDelete} disabled={confirmName !== deleteOf?.name}>Удалить навсегда</Button>
</>}>
{deleteOf && (
<div className="space-y-3">
<p className="text-sm text-red-600">
Все данные организации <strong>{deleteOf.name}</strong> будут удалены безвозвратно.
Действие необратимо.
</p>
<Field label={`Введи название «${deleteOf.name}» точно для подтверждения`}>
<TextInput value={confirmName} onChange={(e) => setConfirmName(e.target.value)} />
</Field>
</div>
)}
</Modal>
</>
)
}

View file

@ -0,0 +1,182 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArrowRight, ArrowLeft, Check, Copy } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, TextInput, Select } from '@/components/Field'
import { useCountries, useCurrencies } from '@/lib/useLookups'
interface SetupStatus { needsSetup: boolean; orgCount: number }
export function SuperAdminSetupPage() {
const navigate = useNavigate()
const [step, setStep] = useState(1)
const [name, setName] = useState('')
const [countryCode, setCountryCode] = useState('KZ')
const [defaultCurrencyId, setDefaultCurrencyId] = useState('')
const [bin, setBin] = useState('')
const [phone, setPhone] = useState('')
const [adminLast, setAdminLast] = useState('')
const [adminFirst, setAdminFirst] = useState('')
const [adminEmail, setAdminEmail] = useState('')
const [adminPosition, setAdminPosition] = useState('Директор')
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState<{ email: string; password: string } | null>(null)
const countries = useCountries()
const currencies = useCurrencies()
useEffect(() => {
// Если уже есть организации — не показываем wizard, выкидываем на дашборд.
api.get<SetupStatus>('/api/super-admin/setup-status').then((r) => {
if (!r.data.needsSetup) navigate('/super-admin', { replace: true })
}).catch(() => {})
}, [navigate])
const onCountryChange = (cc: string) => {
setCountryCode(cc)
const c = countries.data?.find((x) => x.code === cc)
if (c?.defaultCurrencyId) setDefaultCurrencyId(c.defaultCurrencyId)
}
const finish = async () => {
setError(null); setBusy(true)
try {
const res = await api.post<{ adminEmail: string; adminTempPassword: string }>(
'/api/super-admin/organizations',
{
org: { name, countryCode, bin: bin || null, address: null, phone: phone || null, email: null,
defaultCurrencyId: defaultCurrencyId || null, accountOwnerUserId: null },
adminLastName: adminLast, adminFirstName: adminFirst,
adminEmail, adminPosition: adminPosition || null,
})
setDone({ email: res.data.adminEmail, password: res.data.adminTempPassword })
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? (e as Error).message
setError(msg)
} finally { setBusy(false) }
}
if (done) {
return (
<div className="h-full overflow-auto">
<div className="max-w-xl mx-auto p-4 sm:p-8 space-y-5">
<div className="text-center space-y-2">
<div className="w-16 h-16 mx-auto rounded-full bg-emerald-100 text-emerald-600 flex items-center justify-center">
<Check className="w-8 h-8" />
</div>
<h1 className="text-2xl font-bold">Готово!</h1>
<p className="text-slate-600 dark:text-slate-400">Организация создана. Сохраните данные для входа администратора.</p>
</div>
<div className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-5 space-y-3">
<Field label="Логин (email)">
<div className="flex gap-2">
<TextInput value={done.email} readOnly />
<Button variant="secondary" size="sm" onClick={() => navigator.clipboard?.writeText(done.email)}>
<Copy className="w-4 h-4" />
</Button>
</div>
</Field>
<Field label="Временный пароль">
<div className="flex gap-2">
<TextInput value={done.password} readOnly className="font-mono" />
<Button variant="secondary" size="sm" onClick={() => navigator.clipboard?.writeText(done.password)}>
<Copy className="w-4 h-4" />
</Button>
</div>
</Field>
</div>
<Button onClick={() => navigate('/super-admin')}>Перейти к консоли</Button>
</div>
</div>
)
}
const canStep2 = name.trim() && countryCode && defaultCurrencyId
const canStep3 = adminLast.trim() && adminFirst.trim() && adminEmail.trim()
return (
<div className="h-full overflow-auto">
<div className="max-w-xl mx-auto p-4 sm:p-8 space-y-5">
<div>
<div className="text-xs text-slate-500 mb-1">Шаг {step} из 3</div>
<div className="h-1.5 rounded-full bg-slate-100 dark:bg-slate-800 overflow-hidden">
<div className="h-full bg-[var(--color-brand)] transition-all" style={{ width: `${(step / 3) * 100}%` }} />
</div>
</div>
{error && <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>}
{step === 1 && (
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-6 space-y-3">
<h2 className="text-xl font-bold">Добро пожаловать в Food Market</h2>
<p className="text-slate-600 dark:text-slate-400">
Этот установщик поможет создать первую организацию и подготовить систему к работе.
Займёт минуту: укажете данные магазина, заведёте первого администратора и можно работать.
</p>
<Button onClick={() => setStep(2)}>
Поехали <ArrowRight className="w-4 h-4" />
</Button>
</section>
)}
{step === 2 && (
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-6 space-y-3">
<h2 className="text-xl font-bold">Данные организации</h2>
<Field label="Название организации *">
<TextInput value={name} onChange={(e) => setName(e.target.value)} placeholder="Например, Магазин у дома" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Страна *">
<Select value={countryCode} onChange={(e) => onCountryChange(e.target.value)}>
{countries.data?.map((c) => <option key={c.code} value={c.code}>{c.name}</option>)}
</Select>
</Field>
<Field label="Валюта по умолчанию *">
<Select value={defaultCurrencyId} onChange={(e) => setDefaultCurrencyId(e.target.value)}>
<option value=""></option>
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code} ({c.symbol})</option>)}
</Select>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="БИН/ИНН"><TextInput value={bin} onChange={(e) => setBin(e.target.value)} /></Field>
<Field label="Телефон"><TextInput value={phone} onChange={(e) => setPhone(e.target.value)} /></Field>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setStep(1)}><ArrowLeft className="w-4 h-4" /> Назад</Button>
<Button onClick={() => setStep(3)} disabled={!canStep2}>Далее <ArrowRight className="w-4 h-4" /></Button>
</div>
</section>
)}
{step === 3 && (
<section className="rounded-xl border border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900 p-6 space-y-3">
<h2 className="text-xl font-bold">Первый администратор</h2>
<p className="text-sm text-slate-500">
Этот пользователь получит полный доступ к организации. Временный пароль будет
сгенерирован и показан на следующем шаге.
</p>
<div className="grid grid-cols-2 gap-3">
<Field label="Фамилия *"><TextInput value={adminLast} onChange={(e) => setAdminLast(e.target.value)} /></Field>
<Field label="Имя *"><TextInput value={adminFirst} onChange={(e) => setAdminFirst(e.target.value)} /></Field>
</div>
<Field label="Email (логин) *">
<TextInput type="email" value={adminEmail} onChange={(e) => setAdminEmail(e.target.value)} />
</Field>
<Field label="Должность">
<TextInput value={adminPosition} onChange={(e) => setAdminPosition(e.target.value)} />
</Field>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setStep(2)}><ArrowLeft className="w-4 h-4" /> Назад</Button>
<Button onClick={finish} disabled={!canStep3 || busy}>
{busy ? 'Создаю…' : <>Готово <Check className="w-4 h-4" /></>}
</Button>
</div>
</section>
)}
</div>
</div>
)
}