290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import { Fragment, 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 {
|
||
fetchAdminOrder,
|
||
fetchAdminOrders,
|
||
postAdminOrderMessage,
|
||
setAdminOrderStatus,
|
||
} from '@/entities/order/api/admin-order-api'
|
||
import { ORDER_STATUSES, getAdminNextOrderStatuses } from '@/shared/constants/order'
|
||
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'
|
||
import { RichTextMessageContent } from '@/shared/ui/RichTextMessageContent'
|
||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||
|
||
export function AdminOrdersPage() {
|
||
const qc = useQueryClient()
|
||
const [q, setQ] = useState('')
|
||
const [status, setStatus] = useState('')
|
||
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup' | ''>('')
|
||
const [dialogOpen, setDialogOpen] = useState(false)
|
||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||
const [msg, setMsg] = useState('')
|
||
|
||
const ordersQuery = useQuery({
|
||
queryKey: ['admin', 'orders', { q, status, deliveryType }],
|
||
queryFn: () =>
|
||
fetchAdminOrders({
|
||
q: q.trim() || undefined,
|
||
status: status || undefined,
|
||
deliveryType: deliveryType || undefined,
|
||
}),
|
||
})
|
||
|
||
const orderDetailQuery = useQuery({
|
||
queryKey: ['admin', 'orders', 'detail', selectedId],
|
||
queryFn: () => fetchAdminOrder(selectedId!),
|
||
enabled: Boolean(selectedId),
|
||
})
|
||
|
||
const statusMut = useMutation({
|
||
mutationFn: (next: string) => setAdminOrderStatus(selectedId!, next),
|
||
onSuccess: async () => {
|
||
await invalidateQueryKeys(qc, [
|
||
['admin', 'orders'],
|
||
['admin', 'orders', 'detail'],
|
||
['admin', 'orders', 'summary'],
|
||
])
|
||
},
|
||
})
|
||
|
||
const msgMut = useMutation({
|
||
mutationFn: () => postAdminOrderMessage(selectedId!, msg.trim()),
|
||
onSuccess: async () => {
|
||
setMsg('')
|
||
await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']])
|
||
},
|
||
})
|
||
|
||
const open = (id: string) => {
|
||
setSelectedId(id)
|
||
setDialogOpen(true)
|
||
}
|
||
|
||
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
|
||
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||
|
||
const nextStatuses = useMemo(() => {
|
||
if (!detail) return []
|
||
return getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery')
|
||
}, [detail])
|
||
|
||
return (
|
||
<Box>
|
||
<Typography variant="h4" sx={{ mb: 2 }}>
|
||
Заказы
|
||
</Typography>
|
||
|
||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||
Управление заказами доступно пользователю с правами администратора.
|
||
</Typography>
|
||
<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>
|
||
<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 colSpan={6} sx={{ fontWeight: 700, bgcolor: 'action.hover' }}>
|
||
{orderStatusLabelRu(group.statusCode)} ({group.items.length})
|
||
</TableCell>
|
||
</TableRow>
|
||
{group.items.map((o) => (
|
||
<TableRow key={o.id} hover>
|
||
<TableCell>{o.id.slice(-8)}</TableCell>
|
||
<TableCell>{o.user.email}</TableCell>
|
||
<TableCell>{new Date(o.createdAt).toLocaleString('ru-RU')}</TableCell>
|
||
<TableCell>{formatPriceRub(o.totalCents)}</TableCell>
|
||
<TableCell>{o.itemsCount}</TableCell>
|
||
<TableCell align="right">
|
||
<Button size="small" onClick={() => open(o.id)}>
|
||
Открыть
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</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>
|
||
<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} · {orderStatusLabelRu(detail.status)} ·{' '}
|
||
{formatPriceRub(detail.totalCents)}
|
||
</Typography>
|
||
|
||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ 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}>
|
||
{orderStatusLabelRu(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.25,
|
||
border: 1,
|
||
borderColor: 'divider',
|
||
borderRadius: 2,
|
||
bgcolor: m.authorType === 'admin' ? 'primary.50' : 'grey.100',
|
||
alignSelf: m.authorType === 'admin' ? 'flex-end' : 'flex-start',
|
||
width: 'fit-content',
|
||
maxWidth: '85%',
|
||
}}
|
||
>
|
||
<Typography variant="caption" color="text.secondary">
|
||
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} ·{' '}
|
||
{new Date(m.createdAt).toLocaleString()}
|
||
</Typography>
|
||
<RichTextMessageContent value={m.text} />
|
||
</Box>
|
||
))}
|
||
{detail.messages.length === 0 && <Typography color="text.secondary">Нет сообщений.</Typography>}
|
||
</Stack>
|
||
|
||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||
<Box sx={{ flexGrow: 1, width: '100%' }}>
|
||
<RichTextMessageEditor value={msg} onChange={setMsg} placeholder="Ответ админа" />
|
||
</Box>
|
||
<Button
|
||
variant="contained"
|
||
onClick={() => msgMut.mutate()}
|
||
disabled={msgMut.isPending || !canSendMessage}
|
||
sx={{ minWidth: 160 }}
|
||
>
|
||
Отправить
|
||
</Button>
|
||
</Stack>
|
||
</Box>
|
||
</Stack>
|
||
)}
|
||
</DialogContent>
|
||
<DialogActions>
|
||
<Button onClick={() => setDialogOpen(false)}>Закрыть</Button>
|
||
</DialogActions>
|
||
</Dialog>
|
||
</Box>
|
||
)
|
||
}
|