feat(web): super-admin section + setup wizard + auto-redirect

Раздел /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 18eb362702
commit e3bc5cacfc
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 { DashboardPage } from '@/pages/DashboardPage'
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 { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage'
@ -66,6 +71,11 @@ export default function App() {
<Route path="/settings/organization" element={<OrganizationSettingsPage />} />
<Route path="/settings/employees" element={<EmployeesPage />} />
<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 path="*" element={<Navigate to="/" replace />} />

View file

@ -6,7 +6,7 @@ import { logout } from '@/lib/auth'
import { cn } from '@/lib/utils'
import {
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,
} from 'lucide-react'
import { Logo } from './Logo'
@ -22,7 +22,7 @@ interface MeResponse {
type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean }
type NavSection = { group: string; items: NavItem[] }
function buildNav(): NavSection[] {
function buildNav(isSuperAdmin: boolean): NavSection[] {
const catalog: NavItem[] = [
{ to: '/catalog/products', icon: Package, label: 'Товары' },
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' },
@ -61,6 +61,14 @@ function buildNav(): NavSection[] {
{ to: '/settings/employees', icon: UserCog, 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,
})
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 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>
)
}