246 lines
10 KiB
TypeScript
246 lines
10 KiB
TypeScript
import { useMemo, useState } from 'react'
|
||
import Alert from '@mui/material/Alert'
|
||
import Box from '@mui/material/Box'
|
||
import Button from '@mui/material/Button'
|
||
import Link from '@mui/material/Link'
|
||
import Stack from '@mui/material/Stack'
|
||
import Typography from '@mui/material/Typography'
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import { useUnit } from 'effector-react'
|
||
import { Link as RouterLink } from 'react-router-dom'
|
||
import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
|
||
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
|
||
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
|
||
import { getAdminNextOrderStatuses } from '@/shared/constants/order'
|
||
import { formatPriceRub } from '@/shared/lib/format-price'
|
||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||
import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot'
|
||
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
|
||
import { $user } from '@/shared/model/auth'
|
||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor.lazy'
|
||
import { UserAvatar } from '@/shared/ui/UserAvatar'
|
||
import { DeliveryFeeAdjustmentForm } from './DeliveryFeeAdjustmentForm'
|
||
|
||
export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDetailResponse['item']; orderId: string }) {
|
||
const qc = useQueryClient()
|
||
const [msg, setMsg] = useState('')
|
||
|
||
const statusMut = useMutation({
|
||
mutationFn: (next: string) => setAdminOrderStatus(orderId, next),
|
||
onSuccess: async () => {
|
||
await invalidateQueryKeys(qc, [
|
||
['admin', 'orders'],
|
||
['admin', 'orders', 'detail'],
|
||
['admin', 'orders', 'summary'],
|
||
])
|
||
},
|
||
})
|
||
|
||
const msgMut = useMutation({
|
||
mutationFn: () => postAdminOrderMessage(orderId, msg.trim()),
|
||
onSuccess: async () => {
|
||
setMsg('')
|
||
await invalidateQueryKeys(qc, [['admin', 'orders', 'detail']])
|
||
},
|
||
})
|
||
|
||
const deliverySnapshot = useMemo(
|
||
() => (detail.deliveryType === 'delivery' ? parseOrderAddressSnapshot(detail.addressSnapshotJson) : null),
|
||
[detail],
|
||
)
|
||
|
||
const nextStatuses = useMemo(
|
||
() => getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery'),
|
||
[detail],
|
||
)
|
||
|
||
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
|
||
const currentUser = useUnit($user)
|
||
|
||
return (
|
||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||
<Typography sx={{ fontWeight: 700 }}>
|
||
#{detail.id.slice(-8)} · {detail.user.email} · {ORDER_STATUS_MAP[detail.status] ?? detail.status} ·{' '}
|
||
{formatPriceRub(detail.totalCents)}
|
||
</Typography>
|
||
<Typography variant="body2" color="text.secondary">
|
||
Получение: {detail.deliveryType === 'pickup' ? 'самовывоз' : 'доставка'}
|
||
{(detail.paymentMethod ?? 'online') === 'on_pickup' ? ' · оплата при получении' : ' · онлайн-оплата'}
|
||
{detail.deliveryType === 'delivery' && deliveryCarrierLabelRu(detail.deliveryCarrier) && (
|
||
<> · служба: {deliveryCarrierLabelRu(detail.deliveryCarrier)}</>
|
||
)}
|
||
</Typography>
|
||
|
||
{detail.deliveryType === 'delivery' && (
|
||
<Box
|
||
sx={{
|
||
border: 1,
|
||
borderColor: 'divider',
|
||
borderRadius: 1,
|
||
p: 1.5,
|
||
bgcolor: 'action.hover',
|
||
}}
|
||
>
|
||
<Typography variant="subtitle2" gutterBottom sx={{ fontWeight: 700 }}>
|
||
Адрес и получатель (на момент заказа)
|
||
</Typography>
|
||
{deliverySnapshot ? (
|
||
<Stack spacing={0.75}>
|
||
{deliverySnapshot.label?.trim() && (
|
||
<Typography variant="body2" color="text.secondary">
|
||
Метка: {deliverySnapshot.label}
|
||
</Typography>
|
||
)}
|
||
<Typography variant="body2">
|
||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||
Адрес:
|
||
</Box>{' '}
|
||
{deliverySnapshot.addressLine ?? '—'}
|
||
</Typography>
|
||
<Typography variant="body2">
|
||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||
Получатель:
|
||
</Box>{' '}
|
||
{deliverySnapshot.recipientName ?? '—'}
|
||
</Typography>
|
||
<Typography variant="body2">
|
||
<Box component="span" sx={{ color: 'text.secondary' }}>
|
||
Телефон:
|
||
</Box>{' '}
|
||
{deliverySnapshot.recipientPhone ?? '—'}
|
||
</Typography>
|
||
{deliverySnapshot.comment?.trim() && (
|
||
<Typography variant="body2" color="text.secondary">
|
||
Комментарий к адресу: {deliverySnapshot.comment}
|
||
</Typography>
|
||
)}
|
||
</Stack>
|
||
) : (
|
||
<Typography color="text.secondary" variant="body2">
|
||
Данные адреса в заказе отсутствуют или не распознаны.
|
||
</Typography>
|
||
)}
|
||
</Box>
|
||
)}
|
||
|
||
<Box>
|
||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||
Товары в заказе
|
||
</Typography>
|
||
<Stack spacing={1}>
|
||
{detail.items.map((item) => (
|
||
<Stack
|
||
key={item.id}
|
||
direction="row"
|
||
spacing={2}
|
||
sx={{ alignItems: 'center', py: 0.5, px: 1, borderRadius: 1, bgcolor: 'action.hover' }}
|
||
>
|
||
<Box sx={{ flexGrow: 1 }}>
|
||
<Link component={RouterLink} to={`/products/${item.productId}`} underline="hover" color="primary">
|
||
{item.titleSnapshot}
|
||
</Link>
|
||
<Typography color="text.secondary" variant="body2">
|
||
{item.qty} × {formatPriceRub(item.priceCentsSnapshot)}
|
||
</Typography>
|
||
</Box>
|
||
<Typography sx={{ fontWeight: 700 }}>{formatPriceRub(item.priceCentsSnapshot * item.qty)}</Typography>
|
||
</Stack>
|
||
))}
|
||
</Stack>
|
||
</Box>
|
||
|
||
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||
<Alert severity="info">
|
||
Укажите итоговую стоимость доставки (₽). После сохранения клиент сможет оплатить заказ с учётом этой суммы.
|
||
</Alert>
|
||
)}
|
||
|
||
{detail.status === 'PENDING_PAYMENT' && detail.deliveryFeeLocked === false && (
|
||
<DeliveryFeeAdjustmentForm key={detail.id} orderId={detail.id} deliveryFeeCents={detail.deliveryFeeCents} />
|
||
)}
|
||
|
||
<Box>
|
||
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||
Быстрый переход статуса
|
||
</Typography>
|
||
{statusMut.isError && <Alert severity="error">Не удалось сменить статус</Alert>}
|
||
{nextStatuses.length === 0 ? (
|
||
<Typography variant="body2" color="text.secondary">
|
||
Статус финальный, смена недоступна
|
||
</Typography>
|
||
) : (
|
||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.25}>
|
||
{nextStatuses.map((nextStatus) => {
|
||
const isCancelled = nextStatus === 'CANCELLED'
|
||
return (
|
||
<Button
|
||
key={nextStatus}
|
||
variant={isCancelled ? 'outlined' : 'contained'}
|
||
color={isCancelled ? 'error' : 'primary'}
|
||
disabled={statusMut.isPending}
|
||
onClick={() => statusMut.mutate(nextStatus)}
|
||
>
|
||
{ORDER_STATUS_MAP[nextStatus] ?? nextStatus}
|
||
</Button>
|
||
)
|
||
})}
|
||
</Stack>
|
||
)}
|
||
</Box>
|
||
|
||
<Box>
|
||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||
Сообщения
|
||
</Typography>
|
||
<Stack spacing={1} sx={{ mb: 1 }}>
|
||
{detail.messages.map((m) => {
|
||
const isAdminMsg = m.authorType === 'admin'
|
||
const avatarNode = isAdminMsg ? (
|
||
currentUser && (
|
||
<UserAvatar
|
||
userId={currentUser.id}
|
||
avatarUrl={currentUser.avatar}
|
||
avatarStyle={currentUser.avatarStyle}
|
||
size={24}
|
||
/>
|
||
)
|
||
) : (
|
||
<UserAvatar
|
||
userId={detail.user.id}
|
||
avatarUrl={detail.user.avatar}
|
||
avatarStyle={detail.user.avatarStyle}
|
||
size={24}
|
||
/>
|
||
)
|
||
return (
|
||
<ChatMessageBubble key={m.id} authorType={isAdminMsg ? 'admin' : 'user'} avatar={avatarNode}>
|
||
<Typography variant="caption" color="text.secondary">
|
||
{isAdminMsg ? 'Админ (вы)' : 'Пользователь'} · {new Date(m.createdAt).toLocaleString()}
|
||
</Typography>
|
||
<OrderMessageBody text={m.text} attachmentUrl={m.attachmentUrl} />
|
||
</ChatMessageBubble>
|
||
)
|
||
})}
|
||
{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>
|
||
)
|
||
}
|