base commit

This commit is contained in:
@kirill.komarov
2026-04-29 19:14:34 +05:00
parent c1773e5c57
commit bfc9661d22
25 changed files with 1885 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
export { AdminOrdersPage } from './ui/AdminOrdersPage'
@@ -0,0 +1,301 @@
import { useEffect, useMemo, 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 FormControl from '@mui/material/FormControl'
import InputLabel from '@mui/material/InputLabel'
import MenuItem from '@mui/material/MenuItem'
import Select from '@mui/material/Select'
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 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 {
fetchAdminOrder,
fetchAdminOrders,
postAdminOrderMessage,
setAdminOrderStatus,
} from '@/entities/order/api/admin-order-api'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
import { formatPriceRub } from '@/shared/lib/format-price'
type TokenFormState = { token: string }
export function AdminOrdersPage() {
const qc = useQueryClient()
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [dialogOpen, setDialogOpen] = useState(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [msg, setMsg] = useState('')
const tokenForm = useForm<TokenFormState>({ defaultValues: { token: '' }, mode: 'onChange' })
useEffect(() => {
tokenForm.reset({ token: '' })
}, [token, tokenForm])
const saveToken = () => {
const t = tokenForm.getValues('token').trim()
if (!t) {
clearAdminToken()
setTokenState(null)
return
}
setAdminToken(t)
setTokenState(t)
}
const ordersQuery = useQuery({
queryKey: ['admin', 'orders', token, { q, status }],
queryFn: () => fetchAdminOrders(token!, { q: q.trim() || undefined, status: status || undefined }),
enabled: Boolean(token),
})
const orderDetailQuery = useQuery({
queryKey: ['admin', 'orders', 'detail', token, selectedId],
queryFn: () => fetchAdminOrder(token!, selectedId!),
enabled: Boolean(token && selectedId),
})
const statusMut = useMutation({
mutationFn: (next: string) => setAdminOrderStatus(token!, selectedId!, next),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['admin', 'orders'] })
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] })
},
})
const msgMut = useMutation({
mutationFn: () => postAdminOrderMessage(token!, selectedId!, msg.trim()),
onSuccess: async () => {
setMsg('')
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] })
},
})
const open = (id: string) => {
setSelectedId(id)
setDialogOpen(true)
}
const items = ordersQuery.data?.items ?? []
const detail = orderDetailQuery.data?.item
const nextStatuses = useMemo(() => {
const s = detail?.status
if (!s) return []
const map: Record<string, string[]> = {
DRAFT: ['PENDING_PAYMENT', 'CANCELLED'],
PENDING_PAYMENT: ['PAID', 'CANCELLED'],
PAID: ['IN_PROGRESS', 'CANCELLED'],
IN_PROGRESS: ['SHIPPED', 'CANCELLED'],
SHIPPED: ['DONE'],
}
return map[s] ?? []
}, [detail?.status])
return (
<Box>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2, alignItems: { sm: 'center' } }}>
<Typography variant="h4" sx={{ flexGrow: 1 }}>
Админка заказы
</Typography>
<Button component={RouterLink} to="/admin" variant="outlined">
Товары
</Button>
<Button component={RouterLink} to="/admin/reviews" variant="outlined">
Отзывы
</Button>
</Stack>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите API-токен из <code>ADMIN_API_TOKEN</code> (сохраняется в sessionStorage).
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ mb: 2 }}>
<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 }}>
<TextField
size="small"
label="Поиск (id/email)"
value={q}
onChange={(e) => setQ(e.target.value)}
fullWidth
/>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="status-label">Статус</InputLabel>
<Select
labelId="status-label"
label="Статус"
value={status}
onChange={(e) => setStatus(String(e.target.value))}
>
<MenuItem value="">
<em>Все</em>
</MenuItem>
{['DRAFT', 'PENDING_PAYMENT', 'PAID', 'IN_PROGRESS', 'SHIPPED', 'DONE', 'CANCELLED'].map((s) => (
<MenuItem key={s} value={s}>
{s}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
{ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>}
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Покупатель</TableCell>
<TableCell>Статус</TableCell>
<TableCell>Сумма</TableCell>
<TableCell>Позиций</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((o) => (
<TableRow key={o.id} hover>
<TableCell>{o.id.slice(-8)}</TableCell>
<TableCell>{o.user.email}</TableCell>
<TableCell>{o.status}</TableCell>
<TableCell>{formatPriceRub(o.totalCents)}</TableCell>
<TableCell>{o.itemsCount}</TableCell>
<TableCell align="right">
<Button size="small" onClick={() => open(o.id)}>
Открыть
</Button>
</TableCell>
</TableRow>
))}
{ordersQuery.isSuccess && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
Заказов пока нет.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</>
)}
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} fullWidth maxWidth="md">
<DialogTitle>Заказ</DialogTitle>
<DialogContent>
{!detail && orderDetailQuery.isLoading && <Typography>Загрузка</Typography>}
{orderDetailQuery.isError && <Alert severity="error">Не удалось загрузить заказ.</Alert>}
{detail && (
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography sx={{ fontWeight: 700 }}>
#{detail.id.slice(-8)} · {detail.user.email} · {detail.status} · {formatPriceRub(detail.totalCents)}
</Typography>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'center' }}>
<FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel id="next-status-label">Сменить статус</InputLabel>
<Select
labelId="next-status-label"
label="Сменить статус"
value=""
onChange={(e) => {
const next = String(e.target.value)
if (!next) return
statusMut.mutate(next)
}}
disabled={statusMut.isPending || nextStatuses.length === 0}
>
<MenuItem value="">
<em>Выберите</em>
</MenuItem>
{nextStatuses.map((s) => (
<MenuItem key={s} value={s}>
{s}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Сообщения
</Typography>
<Stack spacing={1} sx={{ mb: 1 }}>
{detail.messages.map((m) => (
<Box key={m.id} sx={{ p: 1, border: 1, borderColor: 'divider', borderRadius: 2 }}>
<Typography variant="caption" color="text.secondary">
{m.authorType} · {new Date(m.createdAt).toLocaleString()}
</Typography>
<Typography sx={{ whiteSpace: 'pre-wrap' }}>{m.text}</Typography>
</Box>
))}
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems={{ sm: 'flex-end' }}>
<TextField
label="Ответ админа"
value={msg}
onChange={(e) => setMsg(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Button
variant="contained"
onClick={() => msgMut.mutate()}
disabled={msgMut.isPending || !msg.trim()}
sx={{ minWidth: 160 }}
>
Отправить
</Button>
</Stack>
</Box>
</Stack>
)}
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
</Box>
)
}