test commit
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
import { useState } from 'react'
|
||||
import Button from '@mui/material/Button'
|
||||
import Stack from '@mui/material/Stack'
|
||||
import TextField from '@mui/material/TextField'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { patchAdminOrderDeliveryFee } from '@/entities/order/api/admin-order-api'
|
||||
import { invalidateQueryKeys } from '@/shared/lib/invalidate-query-keys'
|
||||
|
||||
export function DeliveryFeeAdjustmentForm({
|
||||
orderId,
|
||||
deliveryFeeCents,
|
||||
}: {
|
||||
orderId: string
|
||||
deliveryFeeCents: number
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [rub, setRub] = useState(() => String(deliveryFeeCents / 100))
|
||||
const feeMut = useMutation({
|
||||
mutationFn: () => patchAdminOrderDeliveryFee(orderId, Math.round(Number.parseFloat(rub) * 100)),
|
||||
onSuccess: async () => {
|
||||
await invalidateQueryKeys(qc, [
|
||||
['admin', 'orders'],
|
||||
['admin', 'orders', 'detail'],
|
||||
['admin', 'orders', 'summary'],
|
||||
])
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'flex-end' } }}>
|
||||
<TextField
|
||||
size="small"
|
||||
label="Доставка, ₽"
|
||||
type="number"
|
||||
value={rub}
|
||||
onChange={(e) => setRub(e.target.value)}
|
||||
slotProps={{ htmlInput: { min: 0, step: 1 } }}
|
||||
sx={{ width: { xs: '100%', sm: 200 } }}
|
||||
/>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={
|
||||
feeMut.isPending ||
|
||||
!rub.trim() ||
|
||||
!Number.isFinite(Number.parseFloat(rub)) ||
|
||||
Number.parseFloat(rub) < 0 ||
|
||||
!Number.isInteger(Number.parseFloat(rub))
|
||||
}
|
||||
onClick={() => feeMut.mutate()}
|
||||
>
|
||||
Утвердить доставку и открыть оплату
|
||||
</Button>
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import Alert from '@mui/material/Alert'
|
||||
import Box from '@mui/material/Box'
|
||||
import Button from '@mui/material/Button'
|
||||
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 Typography from '@mui/material/Typography'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
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 { orderStatusLabelRu } from '@/shared/lib/order-status-labels'
|
||||
import { ChatMessageBubble } from '@/shared/ui/ChatMessageBubble'
|
||||
import { OrderMessageBody } from '@/shared/ui/OrderMessageBody'
|
||||
import { RichTextMessageEditor } from '@/shared/ui/RichTextMessageEditor'
|
||||
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
|
||||
|
||||
return (
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Typography sx={{ fontWeight: 700 }}>
|
||||
#{detail.id.slice(-8)} · {detail.user.email} · {orderStatusLabelRu(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>
|
||||
)}
|
||||
|
||||
{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} />
|
||||
)}
|
||||
|
||||
<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) => (
|
||||
<ChatMessageBubble key={m.id} authorType={m.authorType === 'admin' ? 'user' : 'admin'}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{m.authorType === 'admin' ? 'Админ (вы)' : 'Пользователь'} · {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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user