This commit is contained in:
@kirill.komarov
2026-05-11 15:14:35 +05:00
parent 20096c1eec
commit 4eda6d0f81
20 changed files with 299 additions and 56 deletions
@@ -4,6 +4,7 @@ export type AdminOrderListItem = {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: string | null
paymentMethod?: 'online' | 'on_pickup'
totalCents: number
currency: string
@@ -25,6 +26,7 @@ export type AdminOrderDetailResponse = {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: string | null
paymentMethod?: 'online' | 'on_pickup'
itemsSubtotalCents: number
deliveryFeeCents: number
@@ -77,6 +79,10 @@ export async function setAdminOrderStatus(id: string, status: string): Promise<v
await apiClient.patch(`admin/orders/${id}/status`, { status })
}
export async function patchAdminOrderDeliveryFee(id: string, deliveryFeeCents: number): Promise<void> {
await apiClient.patch(`admin/orders/${id}/delivery-fee`, { deliveryFeeCents })
}
export async function postAdminOrderMessage(id: string, text: string): Promise<void> {
await apiClient.post(`admin/orders/${id}/messages`, { text })
}
@@ -1,4 +1,5 @@
import { apiClient } from '@/shared/api/client'
import type { DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
export type OrderListItem = {
id: string
@@ -19,6 +20,7 @@ export type OrderDetailResponse = {
id: string
status: string
deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: DeliveryCarrierCode | null
paymentMethod?: OrderPaymentMethod
itemsSubtotalCents: number
deliveryFeeCents: number
@@ -47,6 +49,7 @@ export type OrderDetailResponse = {
export async function createOrder(body: {
deliveryType: 'delivery' | 'pickup'
deliveryCarrier?: DeliveryCarrierCode | null
paymentMethod?: OrderPaymentMethod
addressId?: string | null
comment?: string | null
@@ -22,18 +22,63 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
fetchAdminOrder,
fetchAdminOrders,
patchAdminOrderDeliveryFee,
postAdminOrderMessage,
setAdminOrderStatus,
} from '@/entities/order/api/admin-order-api'
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
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 { 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'
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)}
inputProps={{ 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>
)
}
export function AdminOrdersPage() {
const qc = useQueryClient()
const [q, setQ] = useState('')
@@ -96,6 +141,11 @@ export function AdminOrdersPage() {
const detail = orderDetailQuery.data?.item
const canSendMessage = msg.replace(/<[^>]*>/g, ' ').trim().length > 0
const deliverySnapshot = useMemo(
() => (detail?.deliveryType === 'delivery' ? parseOrderAddressSnapshot(detail.addressSnapshotJson) : null),
[detail],
)
const nextStatuses = useMemo(() => {
if (!detail) return []
return getAdminNextOrderStatuses(detail.status, detail.deliveryType ?? 'delivery')
@@ -211,8 +261,78 @@ export function AdminOrdersPage() {
<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 === 'DELIVERY_FEE_ADJUSTMENT' && (
<Alert severity="info">
Укажите итоговую стоимость доставки (). После сохранения заказ получит статус «
{orderStatusLabelRu('PENDING_PAYMENT')}», и клиент сможет оплатить с учётом этой суммы.
</Alert>
)}
{detail.status === 'DELIVERY_FEE_ADJUSTMENT' && (
<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>
+23 -10
View File
@@ -19,6 +19,7 @@ import { Link as RouterLink, useNavigate } from 'react-router-dom'
import { fetchMyCart } from '@/entities/cart/api/cart-api'
import { createOrder } from '@/entities/order/api/order-api'
import { fetchMyAddresses } from '@/entities/user/api/address-api'
import { DELIVERY_CARRIER_OPTIONS, type DeliveryCarrierCode } from '@/shared/constants/delivery-carrier'
import { formatPriceRub } from '@/shared/lib/format-price'
import { $user } from '@/shared/model/auth'
@@ -28,6 +29,7 @@ export function CheckoutPage() {
const navigate = useNavigate()
const [deliveryType, setDeliveryType] = useState<'delivery' | 'pickup'>('delivery')
const [pickupPayment, setPickupPayment] = useState<'online' | 'on_pickup'>('online')
const [deliveryCarrier, setDeliveryCarrier] = useState<DeliveryCarrierCode>('RUSSIAN_POST')
const [addressId, setAddressId] = useState('')
const [comment, setComment] = useState('')
@@ -50,6 +52,7 @@ export function CheckoutPage() {
mutationFn: () =>
createOrder({
deliveryType,
deliveryCarrier: deliveryType === 'delivery' ? deliveryCarrier : null,
paymentMethod: deliveryType === 'delivery' ? 'online' : pickupPayment,
addressId: deliveryType === 'delivery' ? selectedAddressId : null,
comment: comment.trim() || null,
@@ -74,9 +77,7 @@ export function CheckoutPage() {
const items = cartQuery.data?.items ?? []
const itemsSubtotalCents = items.reduce((s, x) => s + x.product.priceCents * x.qty, 0)
const totalQty = items.reduce((s, x) => s + x.qty, 0)
const deliveryFeeCents =
deliveryType === 'delivery' && items.length > 0 ? 50000 * Math.max(1, Math.ceil(totalQty / 2)) : 0
const deliveryFeeCents = deliveryType === 'delivery' && items.length > 0 ? 50000 : 0
const total = itemsSubtotalCents + deliveryFeeCents
const addresses = addressesQuery.data?.items ?? []
const hasOverLimit = items.some((x) => x.qty > (x.product.inStock ? x.product.quantity : 1))
@@ -153,6 +154,23 @@ export function CheckoutPage() {
{deliveryType === 'delivery' && (
<>
<Box>
<Typography variant="subtitle2" gutterBottom sx={{ mt: 0.5 }}>
Служба доставки
</Typography>
<RadioGroup
value={deliveryCarrier}
onChange={(e) => {
const v = String(e.target.value) as DeliveryCarrierCode
setDeliveryCarrier(v)
}}
>
{DELIVERY_CARRIER_OPTIONS.map((o) => (
<FormControlLabel key={o.code} value={o.code} control={<Radio size="small" />} label={o.label} />
))}
</RadioGroup>
</Box>
<FormControl size="small" fullWidth>
<InputLabel id="addr-label">Адрес доставки</InputLabel>
<Select
@@ -180,13 +198,8 @@ export function CheckoutPage() {
)}
<Alert severity="info">
Стоимость доставки: 500 за каждые 2 единицы (минимум 500 ).
{items.length > 0 && (
<>
{' '}
В этом заказе: {totalQty} шт. доставка {formatPriceRub(deliveryFeeCents)}.
</>
)}
Стоимость доставки ориентировочно 300 . Точная цена будет скорректирована после расчёта. В сумме заказа
сейчас заложено {items.length > 0 ? formatPriceRub(deliveryFeeCents) : '500 ₽'} до уточнения.
</Alert>
</>
)}
@@ -24,9 +24,11 @@ import {
} from '@/entities/order/api/order-api'
import { postProductReview, uploadReviewImage } from '@/entities/product/api/reviews-api'
import { markOrderMessagesRead } from '@/entities/user/api/messages-api'
import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
import { PAYMENT_TRANSFER_INSTRUCTIONS_PLAIN } from '@/shared/constants/payment-instructions'
import { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point'
import { formatPriceRub } from '@/shared/lib/format-price'
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'
@@ -62,15 +64,6 @@ function reviewSubmitErrorMessage(err: unknown): string {
return 'Не удалось отправить отзыв'
}
type AddressSnapshot = {
deliveryType?: 'delivery' | 'pickup'
label?: string | null
recipientName?: string
recipientPhone?: string
addressLine?: string
comment?: string | null
}
export function OrderDetailPage() {
const { id } = useParams()
const qc = useQueryClient()
@@ -180,14 +173,7 @@ export function OrderDetailPage() {
})()
}, [id, order, orderQuery.status, qc])
const address = useMemo((): AddressSnapshot | null => {
if (!order?.addressSnapshotJson) return null
try {
return JSON.parse(order.addressSnapshotJson) as AddressSnapshot
} catch {
return null
}
}, [order])
const address = useMemo(() => parseOrderAddressSnapshot(order?.addressSnapshotJson), [order?.addressSnapshotJson])
if (!id) return <Alert severity="error">Некорректный заказ.</Alert>
if (orderQuery.isLoading) return <Typography>Загрузка</Typography>
@@ -245,6 +231,11 @@ export function OrderDetailPage() {
Способ: {order.deliveryType === 'pickup' ? 'Самовывоз' : 'Доставка'}
{order.deliveryType === 'pickup' && <> · оплата: {payOnPickup ? 'при получении' : 'онлайн'}</>}
</Typography>
{order.deliveryType === 'delivery' && deliveryCarrierLabelRu(order.deliveryCarrier) && (
<Typography variant="body2" sx={{ mb: 1 }}>
Служба: {deliveryCarrierLabelRu(order.deliveryCarrier)}
</Typography>
)}
{order.deliveryType === 'delivery' && (
<>
{address ? (
@@ -298,6 +289,12 @@ export function OrderDetailPage() {
</Typography>
) : (
<>
{order.status === 'DELIVERY_FEE_ADJUSTMENT' && (
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
Точную стоимость доставки уточняет администратор. Оплата станет доступна после перехода заказа в
статус «{orderStatusLabelRu('PENDING_PAYMENT')}».
</Typography>
)}
{order.status === 'PENDING_PAYMENT' && (
<>
<Typography color="text.secondary" variant="body2" sx={{ mb: 1 }}>
@@ -320,7 +317,7 @@ export function OrderDetailPage() {
Оплата отправлена на проверку. Мы проверим поступление и обновим статус.
</Typography>
)}
{!['PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && (
{!['DELIVERY_FEE_ADJUSTMENT', 'PENDING_PAYMENT', 'PAYMENT_VERIFICATION'].includes(order.status) && (
<Typography color="text.secondary" variant="body2">
На этом этапе действий по оплате в этом блоке не требуется.
</Typography>
@@ -0,0 +1,20 @@
export const DELIVERY_CARRIER_CODES = ['RUSSIAN_POST', 'OZON_PVZ', 'YANDEX_PVZ', 'FIVE_POST'] as const
export type DeliveryCarrierCode = (typeof DELIVERY_CARRIER_CODES)[number]
/** Варианты для формы чекаута (код → подпись). */
export const DELIVERY_CARRIER_OPTIONS: ReadonlyArray<{ code: DeliveryCarrierCode; label: string }> = [
{ code: 'RUSSIAN_POST', label: 'Почта России' },
{ code: 'OZON_PVZ', label: 'Озон доставка (пункт выдачи)' },
{ code: 'YANDEX_PVZ', label: 'Яндекс доставка (пункт выдачи)' },
{ code: 'FIVE_POST', label: '5Post (пункт выдачи)' },
]
const carrierLabelMap: Record<DeliveryCarrierCode, string> = Object.fromEntries(
DELIVERY_CARRIER_OPTIONS.map((o) => [o.code, o.label]),
) as Record<DeliveryCarrierCode, string>
export function deliveryCarrierLabelRu(code: string | null | undefined): string | null {
if (!code) return null
return carrierLabelMap[code as DeliveryCarrierCode] ?? code
}
+3
View File
@@ -1,5 +1,6 @@
export const ORDER_STATUSES = [
'DRAFT',
'DELIVERY_FEE_ADJUSTMENT',
'PENDING_PAYMENT',
'PAYMENT_VERIFICATION',
'PAID',
@@ -17,6 +18,8 @@ export function getAdminNextOrderStatuses(status: string, deliveryType: 'deliver
switch (status) {
case 'DRAFT':
return ['PENDING_PAYMENT', 'CANCELLED']
case 'DELIVERY_FEE_ADJUSTMENT':
return ['CANCELLED']
case 'PENDING_PAYMENT':
return ['CANCELLED']
case 'PAYMENT_VERIFICATION':
@@ -0,0 +1,18 @@
/** Снимок адреса из addressSnapshotJson при оформлении заказа */
export type OrderAddressSnapshot = {
deliveryType?: 'delivery' | 'pickup'
label?: string | null
recipientName?: string
recipientPhone?: string
addressLine?: string
comment?: string | null
}
export function parseOrderAddressSnapshot(json: string | null | undefined): OrderAddressSnapshot | null {
if (!json) return null
try {
return JSON.parse(json) as OrderAddressSnapshot
} catch {
return null
}
}
@@ -2,6 +2,7 @@
export function orderStatusLabelRu(code: string): string {
const map: Record<string, string> = {
DRAFT: 'Черновик',
DELIVERY_FEE_ADJUSTMENT: 'Корректировка стоимости доставки',
PENDING_PAYMENT: 'Ожидает оплаты',
PAYMENT_VERIFICATION: 'Проверка оплаты',
PAID: 'Оплачен',