feat(web): super-admin section + setup wizard + auto-redirect
Some checks are pending
Some checks are pending
Раздел /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:
parent
9482eea050
commit
94dbb5b235
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
25
src/food-market.web/src/lib/useMe.ts
Normal file
25
src/food-market.web/src/lib/useMe.ts
Normal 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')
|
||||
}
|
||||
57
src/food-market.web/src/pages/SuperAdminAuditLogPage.tsx
Normal file
57
src/food-market.web/src/pages/SuperAdminAuditLogPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
80
src/food-market.web/src/pages/SuperAdminDashboardPage.tsx
Normal file
80
src/food-market.web/src/pages/SuperAdminDashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
142
src/food-market.web/src/pages/SuperAdminOrgCreatePage.tsx
Normal file
142
src/food-market.web/src/pages/SuperAdminOrgCreatePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
165
src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx
Normal file
165
src/food-market.web/src/pages/SuperAdminOrganizationsPage.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
182
src/food-market.web/src/pages/SuperAdminSetupPage.tsx
Normal file
182
src/food-market.web/src/pages/SuperAdminSetupPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue