base commit

This commit is contained in:
@kirill.komarov
2026-05-03 19:57:12 +05:00
parent 9139a24093
commit fe10f25b8c
53 changed files with 2064 additions and 1071 deletions
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'
import { Fragment, useMemo, useState } from 'react'
import Alert from '@mui/material/Alert'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
@@ -19,7 +19,6 @@ 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 {
fetchAdminOrder,
fetchAdminOrders,
@@ -27,15 +26,14 @@ import {
setAdminOrderStatus,
} from '@/entities/order/api/admin-order-api'
import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order'
import { clearAdminToken, getAdminToken, setAdminToken } from '@/shared/lib/admin-token'
import { formatPriceRub } from '@/shared/lib/format-price'
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
import { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
type TokenFormState = { token: string }
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
export function AdminOrdersPage() {
const qc = useQueryClient()
const [token, setTokenState] = useState<string | null>(() => getAdminToken())
const [q, setQ] = useState('')
const [status, setStatus] = useState('')
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('')
@@ -43,54 +41,38 @@ export function AdminOrdersPage() {
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, deliveryType }],
queryKey: ['admin', 'orders', { q, status, deliveryType }],
queryFn: () =>
fetchAdminOrders(token!, {
fetchAdminOrders({
q: q.trim() || undefined,
status: status || undefined,
deliveryType: deliveryType || undefined,
}),
enabled: Boolean(token),
})
const orderDetailQuery = useQuery({
queryKey: ['admin', 'orders', 'detail', token, selectedId],
queryFn: () => fetchAdminOrder(token!, selectedId!),
enabled: Boolean(token && selectedId),
queryKey: ['admin', 'orders', 'detail', selectedId],
queryFn: () => fetchAdminOrder(selectedId!),
enabled: Boolean(selectedId),
})
const statusMut = useMutation({
mutationFn: (next: string) => setAdminOrderStatus(token!, selectedId!, next),
mutationFn: (next: string) => setAdminOrderStatus(selectedId!, next),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['admin', 'orders'] })
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] })
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'summary'] })
await invalidateQueryKeys(qc, [
['admin', 'orders'],
['admin', 'orders', 'detail'],
['admin', 'orders', 'summary'],
])
},
})
const msgMut = useMutation({
mutationFn: () => postAdminOrderMessage(token!, selectedId!, msg.trim()),
mutationFn: () => postAdminOrderMessage(selectedId!, msg.trim()),
onSuccess: async () => {
setMsg('')
await qc.invalidateQueries({ queryKey: ['admin', 'orders', 'detail'] })
await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']])
},
})
@@ -99,7 +81,15 @@ export function AdminOrdersPage() {
setDialogOpen(true)
}
const items = ordersQuery.data?.items ?? []
const items = useMemo(() => ordersQuery.data?.items ?? [], [ordersQuery.data?.items])
const groupedItems = useMemo(
() =>
groupOrdersByStatus(items, ORDER_STATUSES).map((group) => ({
statusCode: group.status,
items: group.items,
})),
[items],
)
const detail = orderDetailQuery.data?.item
@@ -115,97 +105,74 @@ export function AdminOrdersPage() {
</Typography>
<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>
<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>
{ORDER_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{orderStatusLabelRu(s)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="delivery-type-label">Способ получения</InputLabel>
<Select
labelId="delivery-type-label"
label="Способ получения"
value={deliveryType}
onChange={(e) => {
const v = String(e.target.value)
if (v === '' || v === 'delivery' || v === 'pickup') setDeliveryType(v)
}}
>
<MenuItem value="">
<em>Все</em>
</MenuItem>
<MenuItem value="delivery">Доставка</MenuItem>
<MenuItem value="pickup">Самовывоз</MenuItem>
</Select>
</FormControl>
</Stack>
{!token && <Alert severity="info">После сохранения токена появится список заказов.</Alert>}
{ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</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>
{ORDER_STATUSES.map((s) => (
<MenuItem key={s} value={s}>
{orderStatusLabelRu(s)}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel id="delivery-type-label">Способ получения</InputLabel>
<Select
labelId="delivery-type-label"
label="Способ получения"
value={deliveryType}
onChange={(e) => {
const v = String(e.target.value)
if (v === '' || v === 'delivery' || v === 'pickup') setDeliveryType(v)
}}
>
<MenuItem value="">
<em>Все</em>
</MenuItem>
<MenuItem value="delivery">Доставка</MenuItem>
<MenuItem value="pickup">Самовывоз</MenuItem>
</Select>
</FormControl>
</Stack>
{ordersQuery.isError && <Alert severity="error">Не удалось загрузить заказы.</Alert>}
<Table size="small">
<TableHead>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Покупатель</TableCell>
<TableCell>Создан</TableCell>
<TableCell>Сумма</TableCell>
<TableCell>Позиций</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{groupedItems.map((group) => (
<Fragment key={`group:${group.statusCode}`}>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Покупатель</TableCell>
<TableCell>Статус</TableCell>
<TableCell>Сумма</TableCell>
<TableCell>Позиций</TableCell>
<TableCell align="right">Действия</TableCell>
<TableCell colSpan={6} sx={{ fontWeight: 700, bgcolor: 'action.hover' }}>
{orderStatusLabelRu(group.statusCode)} ({group.items.length})
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((o) => (
{group.items.map((o) => (
<TableRow key={o.id} hover>
<TableCell>{o.id.slice(-8)}</TableCell>
<TableCell>{o.user.email}</TableCell>
<TableCell>{orderStatusLabelRu(o.status)}</TableCell>
<TableCell>{new Date(o.createdAt).toLocaleString('ru-RU')}</TableCell>
<TableCell>{formatPriceRub(o.totalCents)}</TableCell>
<TableCell>{o.itemsCount}</TableCell>
<TableCell align="right">
@@ -215,17 +182,17 @@ export function AdminOrdersPage() {
</TableCell>
</TableRow>
))}
{ordersQuery.isSuccess && items.length === 0 && (
<TableRow>
<TableCell colSpan={6} sx={{ color: 'text.secondary' }}>
Заказов пока нет.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</>
)}
</Fragment>
))}
{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>
@@ -282,14 +249,9 @@ export function AdminOrdersPage() {
</Stack>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
<TextField
label="Ответ админа"
value={msg}
onChange={(e) => setMsg(e.target.value)}
fullWidth
multiline
minRows={2}
/>
<Box sx={{ flexGrow: 1, width: '100%' }}>
<RichTextMessageEditor value={msg} onChange={setMsg} placeholder="Ответ админа" />
</Box>
<Button
variant="contained"
onClick={() => msgMut.mutate()}