264 lines
8.4 KiB
TypeScript
264 lines
8.4 KiB
TypeScript
import { useEffect, useState } from 'react'
|
||
import Alert from '@mui/material/Alert'
|
||
import Box from '@mui/material/Box'
|
||
import Button from '@mui/material/Button'
|
||
import Stack from '@mui/material/Stack'
|
||
import TableCell from '@mui/material/TableCell'
|
||
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 { useUnit } from 'effector-react'
|
||
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 { getErrorMessage } from '@/shared/lib/get-error-message'
|
||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||
import { useEditDialogState } from '@/shared/lib/use-edit-dialog-state'
|
||
import { $user } from '@/shared/model/auth'
|
||
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
|
||
import { AdminTable } from '@/shared/ui/AdminTable'
|
||
import { EntityRowActions } from '@/shared/ui/EntityRowActions'
|
||
|
||
type UserFormState = {
|
||
email: string
|
||
name: string
|
||
}
|
||
|
||
const emptyUserForm = (): UserFormState => ({ email: '', name: '' })
|
||
|
||
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 { dialogOpen, editing, openCreateDialog, openEditDialog, closeDialog } = useEditDialogState<AdminUser>()
|
||
const [qInput, setQInput] = useState('')
|
||
const [q, setQ] = useState('')
|
||
const [page, setPage] = useState(0)
|
||
const [rowsPerPage, setRowsPerPage] = useState(20)
|
||
const currentUser = useUnit($user)
|
||
const currentUserId = currentUser?.id
|
||
|
||
const userForm = useForm<UserFormState>({
|
||
defaultValues: emptyUserForm(),
|
||
mode: 'onChange',
|
||
})
|
||
|
||
useEffect(() => {
|
||
const t = window.setTimeout(() => {
|
||
setQ(qInput.trim())
|
||
setPage(0)
|
||
}, 250)
|
||
return () => window.clearTimeout(t)
|
||
}, [qInput])
|
||
|
||
const usersQuery = useQuery({
|
||
queryKey: ['admin', 'users', { q, page, rowsPerPage }],
|
||
queryFn: () =>
|
||
fetchAdminUsers({
|
||
q: q || undefined,
|
||
page: page + 1,
|
||
pageSize: rowsPerPage,
|
||
}),
|
||
})
|
||
|
||
const createMut = useMutation({
|
||
mutationFn: async () => {
|
||
const v = userForm.getValues()
|
||
await createAdminUser({
|
||
email: v.email.trim(),
|
||
name: v.name.trim() || null,
|
||
})
|
||
},
|
||
onSuccess: () => {
|
||
void invalidateQueryKeys(queryClient, [['admin', 'users']])
|
||
closeDialog()
|
||
},
|
||
})
|
||
|
||
const updateMut = useMutation({
|
||
mutationFn: async () => {
|
||
const v = userForm.getValues()
|
||
await updateAdminUser(editing!.id, {
|
||
email: v.email.trim(),
|
||
name: v.name.trim() || null,
|
||
})
|
||
},
|
||
onSuccess: () => {
|
||
void invalidateQueryKeys(queryClient, [['admin', 'users']])
|
||
closeDialog()
|
||
},
|
||
})
|
||
|
||
const deleteMut = useMutation({
|
||
mutationFn: async (id: string) => deleteAdminUser(id),
|
||
onSuccess: () => {
|
||
void invalidateQueryKeys(queryClient, [['admin', 'users']])
|
||
},
|
||
})
|
||
|
||
const mutationError = createMut.error ?? updateMut.error ?? deleteMut.error
|
||
|
||
const openCreate = () => {
|
||
userForm.reset(emptyUserForm())
|
||
openCreateDialog()
|
||
}
|
||
|
||
const openEdit = (u: AdminUser) => {
|
||
openEditDialog(u)
|
||
userForm.reset({
|
||
email: u.email,
|
||
name: u.name ?? '',
|
||
})
|
||
}
|
||
|
||
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 }}>
|
||
Управление пользователями доступно пользователю с правами администратора.
|
||
</Typography>
|
||
<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 }}>
|
||
{getErrorMessage(mutationError)}
|
||
</Alert>
|
||
)}
|
||
|
||
<AdminTable
|
||
columns={[
|
||
{ key: 'email', label: 'Почта' },
|
||
{ key: 'name', label: 'Имя' },
|
||
{ key: 'createdAt', label: 'Создан' },
|
||
{ key: 'updatedAt', label: 'Обновлён' },
|
||
{ key: 'actions', label: 'Действия', align: 'right' },
|
||
]}
|
||
loading={usersQuery.isLoading}
|
||
error={usersQuery.isError ? 'Ошибка загрузки.' : null}
|
||
>
|
||
{users.length === 0 && !usersQuery.isLoading ? (
|
||
<TableRow>
|
||
<TableCell colSpan={5} sx={{ color: 'text.secondary' }}>
|
||
Пользователей пока нет.
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
users.map((u) => (
|
||
<TableRow key={u.id} hover>
|
||
<TableCell>{u.email}</TableCell>
|
||
<TableCell>{u.name ?? '—'}</TableCell>
|
||
<TableCell>{formatDt(u.createdAt)}</TableCell>
|
||
<TableCell>{formatDt(u.updatedAt)}</TableCell>
|
||
<TableCell align="right">
|
||
<EntityRowActions
|
||
onEdit={() => openEdit(u)}
|
||
onDelete={u.id === currentUserId ? undefined : () => deleteMut.mutate(u.id)}
|
||
deleteDisabled={deleteMut.isPending}
|
||
confirmDeleteMessage={`Удалить пользователя ${u.email}?`}
|
||
/>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</AdminTable>
|
||
|
||
<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]}
|
||
/>
|
||
|
||
<AdminDialog
|
||
open={dialogOpen}
|
||
onClose={closeDialog}
|
||
title={editing ? 'Редактировать пользователя' : 'Новый пользователь'}
|
||
maxWidth="xs"
|
||
actions={
|
||
<>
|
||
<Button onClick={closeDialog}>Отмена</Button>
|
||
<Button
|
||
variant="contained"
|
||
onClick={() => (editing ? updateMut.mutate() : createMut.mutate())}
|
||
disabled={isSaveDisabled}
|
||
>
|
||
{editing ? 'Сохранить' : 'Создать'}
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||
<Controller
|
||
control={userForm.control}
|
||
name="email"
|
||
render={({ field }) => (
|
||
<TextField
|
||
label="Почта"
|
||
fullWidth
|
||
required
|
||
disabled={Boolean(editing && editing.id === currentUserId)}
|
||
{...field}
|
||
/>
|
||
)}
|
||
/>
|
||
<Controller
|
||
control={userForm.control}
|
||
name="name"
|
||
render={({ field }) => <TextField label="Имя/ник" fullWidth {...field} />}
|
||
/>
|
||
</Stack>
|
||
</AdminDialog>
|
||
</Box>
|
||
)
|
||
}
|