base commit
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
import Dialog from '@mui/material/Dialog'
|
||||
import DialogActions from '@mui/material/DialogActions'
|
||||
import DialogContent from '@mui/material/DialogContent'
|
||||
import DialogTitle from '@mui/material/DialogTitle'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import Table from '@mui/material/Table'
|
||||
import TableBody from '@mui/material/TableBody'
|
||||
import TableCell from '@mui/material/TableCell'
|
||||
import TableHead from '@mui/material/TableHead'
|
||||
import TablePagination from '@mui/material/TablePagination'
|
||||
import TableRow from '@mui/material/TableRow'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Controller, useForm } from 'react-hook-form'
|
||||
import { Link as RouterLink } from 'react-router-dom'
|
||||
import { createAdminUser, deleteAdminUser, fetchAdminUsers, updateAdminUser } from '@/entities/user/api/user-api'
|
||||
import type { AdminUser } from '@/entities/user/model/types'
|
||||
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
|
||||
|
||||
type TokenFormState = { token: string }
|
||||
|
||||
type UserFormState = {
|
||||
email: string
|
||||
name: string
|
||||
password: string
|
||||
}
|
||||
|
||||
const emptyUserForm = (): UserFormState => ({ email: '', name: '', password: '' })
|
||||
|
||||
function formatDt(v: string) {
|
||||
try {
|
||||
const d = new Date(v)
|
||||
if (Number.isNaN(d.getTime())) return '—'
|
||||
return d.toLocaleString()
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
export function AdminUsersPage() {
|
||||
const queryClient = useQueryClient()
|
||||
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<AdminUser | null>(null)
|
||||
const [qInput, setQInput] = useState('')
|
||||
const [q, setQ] = useState('')
|
||||
const [page, setPage] = useState(0)
|
||||
const [rowsPerPage, setRowsPerPage] = useState(20)
|
||||
|
||||
const tokenForm = useForm<TokenFormState>({
|
||||
defaultValues: { token: '' },
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
const userForm = useForm<UserFormState>({
|
||||
defaultValues: emptyUserForm(),
|
||||
mode: 'onChange',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
tokenForm.reset({ token: '' })
|
||||
}, [token, tokenForm])
|
||||
|
||||
useEffect(() => {
|
||||
const t = window.setTimeout(() => {
|
||||
setQ(qInput.trim())
|
||||
setPage(0)
|
||||
}, 250)
|
||||
return () => window.clearTimeout(t)
|
||||
}, [qInput])
|
||||
|
||||
const saveToken = () => {
|
||||
const t = tokenForm.getValues('token').trim()
|
||||
if (!t) {
|
||||
clearAdminToken()
|
||||
setTokenState(null)
|
||||
return
|
||||
}
|
||||
setAdminToken(t)
|
||||
setTokenState(t)
|
||||
}
|
||||
|
||||
const usersQuery = useQuery({
|
||||
queryKey: ['admin', 'users', token, { q, page, rowsPerPage }],
|
||||
queryFn: () =>
|
||||
fetchAdminUsers(token!, {
|
||||
q: q || undefined,
|
||||
page: page + 1,
|
||||
pageSize: rowsPerPage,
|
||||
}),
|
||||
enabled: Boolean(token),
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const v = userForm.getValues()
|
||||
await createAdminUser(token!, {
|
||||
email: v.email.trim(),
|
||||
name: v.name.trim() || null,
|
||||
password: v.password.trim() || undefined,
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
setDialogOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const v = userForm.getValues()
|
||||
await updateAdminUser(token!, editing!.id, {
|
||||
email: v.email.trim(),
|
||||
name: v.name.trim() || null,
|
||||
...(v.password.trim() ? { password: v.password.trim() } : {}),
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
setDialogOpen(false)
|
||||
},
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (id: string) => deleteAdminUser(token!, id),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ['admin', 'users'] })
|
||||
},
|
||||
})
|
||||
|
||||
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
userForm.reset(emptyUserForm())
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (u: AdminUser) => {
|
||||
setEditing(u)
|
||||
userForm.reset({
|
||||
email: u.email,
|
||||
name: u.name ?? '',
|
||||
password: '',
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
const emailValue = userForm.watch('email')
|
||||
const isSaveDisabled = !emailValue.trim() || createMut.isPending || updateMut.isPending
|
||||
|
||||
const users = usersQuery.data?.items ?? []
|
||||
const total = usersQuery.data?.total ?? 0
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
spacing={2}
|
||||
sx={{ mb: 2, alignItems: { sm: 'center' }, justifyContent: 'space-between' }}
|
||||
>
|
||||
<Typography variant="h4">Админка — пользователи</Typography>
|
||||
<Button component={RouterLink} to="/admin" variant="outlined">
|
||||
Товары
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Введите API-токен из{' '}
|
||||
<Typography component="span" sx={{ fontFamily: 'monospace' }}>
|
||||
.env
|
||||
</Typography>{' '}
|
||||
сервера (<code>ADMIN_API_TOKEN</code>). Он сохраняется только в памяти браузера (sessionStorage).
|
||||
</Typography>
|
||||
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 3 }}>
|
||||
<Controller
|
||||
control={tokenForm.control}
|
||||
name="token"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label="Токен (Bearer)"
|
||||
type="password"
|
||||
fullWidth
|
||||
{...field}
|
||||
placeholder={token ? '••••••••' : ''}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Button variant="contained" onClick={saveToken} sx={{ minWidth: 140 }}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</Stack>
|
||||
|
||||
{!token && <Alert severity="info">После сохранения токена здесь появится список пользователей.</Alert>}
|
||||
|
||||
{token && (
|
||||
<>
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
|
||||
<Button variant="contained" onClick={openCreate}>
|
||||
Новый пользователь
|
||||
</Button>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Поиск (email/имя)"
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
{usersQuery.isError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
Ошибка загрузки. Проверьте токен и что сервер запущен.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{mutationError && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>
|
||||
{(mutationError as Error).message}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Почта</TableCell>
|
||||
<TableCell>Имя</TableCell>
|
||||
<TableCell>Пароль</TableCell>
|
||||
<TableCell>Создан</TableCell>
|
||||
<TableCell>Обновлён</TableCell>
|
||||
<TableCell align="right">Действия</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{users.map((u) => (
|
||||
<TableRow key={u.id} hover>
|
||||
<TableCell>{u.email}</TableCell>
|
||||
<TableCell>{u.name ?? '—'}</TableCell>
|
||||
<TableCell>{u.hasPassword ? 'задан' : 'нет'}</TableCell>
|
||||
<TableCell>{formatDt(u.createdAt)}</TableCell>
|
||||
<TableCell>{formatDt(u.updatedAt)}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Button size="small" onClick={() => openEdit(u)}>
|
||||
Изменить
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
color="error"
|
||||
disabled={deleteMut.isPending}
|
||||
onClick={() => {
|
||||
if (!confirm(`Удалить пользователя ${u.email}?`)) return
|
||||
deleteMut.mutate(u.id)
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{users.length === 0 && !usersQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
|
||||
Пользователей пока нет.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={(_, p) => setPage(p)}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={(e) => {
|
||||
setRowsPerPage(Number(e.target.value))
|
||||
setPage(0)
|
||||
}}
|
||||
rowsPerPageOptions={[10, 20, 50, 100]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="xs">
|
||||
<DialogTitle>{editing ? 'Редактировать пользователя' : 'Новый пользователь'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Controller
|
||||
control={userForm.control}
|
||||
name="email"
|
||||
render={({ field }) => <TextField label="Почта" fullWidth required {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={userForm.control}
|
||||
name="name"
|
||||
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
|
||||
/>
|
||||
<Controller
|
||||
control={userForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<TextField
|
||||
label={editing ? 'Новый пароль (необязательно)' : 'Пароль (необязательно)'}
|
||||
type="password"
|
||||
fullWidth
|
||||
helperText="Минимум 8 символов. Для редактирования можно оставить пустым."
|
||||
{...field}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDialogOpen(false)}>Отмена</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
|
||||
disabled={isSaveDisabled}
|
||||
>
|
||||
{editing ? 'Сохранить' : 'Создать'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user