ывав
This commit is contained in:
@@ -1,17 +1,13 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Link as RouterLink } from 'react-router-dom'
|
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import FormControl from '@mui/material/FormControl'
|
|
||||||
import InputLabel from '@mui/material/InputLabel'
|
|
||||||
import Link from '@mui/material/Link'
|
import Link from '@mui/material/Link'
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
|
||||||
import Select from '@mui/material/Select'
|
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Typography from '@mui/material/Typography'
|
import Typography from '@mui/material/Typography'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useUnit } from 'effector-react'
|
import { useUnit } from 'effector-react'
|
||||||
|
import { Link as RouterLink } from 'react-router-dom'
|
||||||
import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
|
import { postAdminOrderMessage, setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
|
||||||
import type { AdminOrderDetailResponse } 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 { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier'
|
||||||
@@ -165,31 +161,34 @@ export function OrderDetailContent({ detail, orderId }: { detail: AdminOrderDeta
|
|||||||
<DeliveryFeeAdjustmentForm key={detail.id} orderId={detail.id} deliveryFeeCents={detail.deliveryFeeCents} />
|
<DeliveryFeeAdjustmentForm key={detail.id} orderId={detail.id} deliveryFeeCents={detail.deliveryFeeCents} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ alignItems: { sm: 'center' } }}>
|
<Box>
|
||||||
<FormControl size="small" sx={{ minWidth: 240 }}>
|
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 700 }}>
|
||||||
<InputLabel id="next-status-label">Сменить статус</InputLabel>
|
Быстрый переход статуса
|
||||||
<Select
|
</Typography>
|
||||||
labelId="next-status-label"
|
{statusMut.isError && <Alert severity="error">Не удалось сменить статус</Alert>}
|
||||||
label="Сменить статус"
|
{nextStatuses.length === 0 ? (
|
||||||
value=""
|
<Typography variant="body2" color="text.secondary">
|
||||||
onChange={(e) => {
|
Статус финальный, смена недоступна
|
||||||
const next = String(e.target.value)
|
</Typography>
|
||||||
if (!next) return
|
) : (
|
||||||
statusMut.mutate(next)
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1.25}>
|
||||||
}}
|
{nextStatuses.map((nextStatus) => {
|
||||||
disabled={statusMut.isPending || nextStatuses.length === 0}
|
const isCancelled = nextStatus === 'CANCELLED'
|
||||||
>
|
return (
|
||||||
<MenuItem value="">
|
<Button
|
||||||
<em>Выберите…</em>
|
key={nextStatus}
|
||||||
</MenuItem>
|
variant={isCancelled ? 'outlined' : 'contained'}
|
||||||
{nextStatuses.map((s) => (
|
color={isCancelled ? 'error' : 'primary'}
|
||||||
<MenuItem key={s} value={s}>
|
disabled={statusMut.isPending}
|
||||||
{ORDER_STATUS_MAP[s] ?? s}
|
onClick={() => statusMut.mutate(nextStatus)}
|
||||||
</MenuItem>
|
>
|
||||||
))}
|
{ORDER_STATUS_MAP[nextStatus] ?? nextStatus}
|
||||||
</Select>
|
</Button>
|
||||||
</FormControl>
|
)
|
||||||
</Stack>
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Box>
|
<Box>
|
||||||
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
<Typography variant="subtitle2" sx={{ mb: 1 }}>
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { useUnit } from 'effector-react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { setAdminOrderStatus } from '@/entities/order/api/admin-order-api'
|
||||||
|
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
|
||||||
|
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
|
||||||
|
import { OrderDetailContent } from '../OrderDetailContent'
|
||||||
|
|
||||||
|
vi.mock('@/entities/order/api/admin-order-api', () => ({
|
||||||
|
setAdminOrderStatus: vi.fn(),
|
||||||
|
postAdminOrderMessage: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('effector-react', () => ({
|
||||||
|
useUnit: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/shared/ui/RichTextMessageEditor.lazy', () => ({
|
||||||
|
RichTextMessageEditor: ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string
|
||||||
|
onChange: (next: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}) => <textarea aria-label="Ответ админа" value={value} onChange={(e) => onChange(e.target.value)} />,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const setAdminOrderStatusMock = vi.mocked(setAdminOrderStatus)
|
||||||
|
const useUnitMock = vi.mocked(useUnit)
|
||||||
|
|
||||||
|
function createDetail(overrides?: Partial<AdminOrderDetailResponse['item']>): AdminOrderDetailResponse['item'] {
|
||||||
|
return {
|
||||||
|
id: 'order-12345678',
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryCarrier: null,
|
||||||
|
paymentMethod: 'online',
|
||||||
|
itemsSubtotalCents: 3000,
|
||||||
|
deliveryFeeCents: 300,
|
||||||
|
deliveryFeeLocked: true,
|
||||||
|
totalCents: 3300,
|
||||||
|
currency: 'RUB',
|
||||||
|
addressSnapshotJson: null,
|
||||||
|
comment: null,
|
||||||
|
createdAt: '2026-05-28T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-28T10:00:00.000Z',
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'buyer@example.com',
|
||||||
|
displayName: 'Покупатель',
|
||||||
|
avatar: null,
|
||||||
|
avatarStyle: null,
|
||||||
|
},
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'item-1',
|
||||||
|
productId: 'product-1',
|
||||||
|
qty: 1,
|
||||||
|
titleSnapshot: 'Тестовый товар',
|
||||||
|
priceCentsSnapshot: 3000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
messages: [],
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void
|
||||||
|
let reject!: (reason?: unknown) => void
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res
|
||||||
|
reject = rej
|
||||||
|
})
|
||||||
|
return { promise, resolve, reject }
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComponent(detail: AdminOrderDetailResponse['item'], orderId = 'order-12345678') {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<OrderDetailContent detail={detail} orderId={orderId} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OrderDetailContent quick status transitions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
useUnitMock.mockReturnValue(null)
|
||||||
|
setAdminOrderStatusMock.mockResolvedValue(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('рендерит кнопки доступных переходов статуса', async () => {
|
||||||
|
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('Быстрый переход статуса')).toBeInTheDocument()
|
||||||
|
expect(await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID })).toBeInTheDocument()
|
||||||
|
const cancelledButton = screen.getByRole('button', { name: ORDER_STATUS_MAP.CANCELLED })
|
||||||
|
expect(cancelledButton).toBeInTheDocument()
|
||||||
|
expect(cancelledButton).toHaveClass('MuiButton-outlined')
|
||||||
|
expect(cancelledButton).toHaveClass('MuiButton-colorError')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('по клику вызывает setAdminOrderStatus(orderId, статус)', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }), 'order-click-test')
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID }))
|
||||||
|
|
||||||
|
expect(setAdminOrderStatusMock).toHaveBeenCalledWith('order-click-test', 'PAID')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('в pending состоянии дизейблит кнопки перехода', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const deferred = createDeferred<void>()
|
||||||
|
setAdminOrderStatusMock.mockImplementationOnce(() => deferred.promise)
|
||||||
|
|
||||||
|
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }))
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID }))
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: ORDER_STATUS_MAP.PAID })).toBeDisabled()
|
||||||
|
expect(screen.getByRole('button', { name: ORDER_STATUS_MAP.CANCELLED })).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
deferred.resolve(undefined)
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByRole('button', { name: ORDER_STATUS_MAP.PAID })).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('для финального статуса показывает сообщение без кнопок перехода', () => {
|
||||||
|
renderComponent(createDetail({ status: 'CANCELLED' }))
|
||||||
|
|
||||||
|
expect(screen.getByText('Статус финальный, смена недоступна')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: ORDER_STATUS_MAP.PAID })).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: ORDER_STATUS_MAP.CANCELLED })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('показывает ошибку мутации и после завершения запроса снова даёт кликнуть', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const deferred = createDeferred<void>()
|
||||||
|
setAdminOrderStatusMock.mockImplementationOnce(() => deferred.promise).mockResolvedValueOnce(undefined)
|
||||||
|
|
||||||
|
renderComponent(createDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' }))
|
||||||
|
|
||||||
|
const paidButton = await screen.findByRole('button', { name: ORDER_STATUS_MAP.PAID })
|
||||||
|
await user.click(paidButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(paidButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
deferred.reject(new Error('request failed'))
|
||||||
|
|
||||||
|
const errorAlert = await screen.findByText('Не удалось сменить статус')
|
||||||
|
expect(errorAlert).toBeInTheDocument()
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(paidButton).not.toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
await user.click(paidButton)
|
||||||
|
expect(setAdminOrderStatusMock).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { Fragment, useMemo, useState } from 'react'
|
|||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
|
import Chip from '@mui/material/Chip'
|
||||||
import FormControl from '@mui/material/FormControl'
|
import FormControl from '@mui/material/FormControl'
|
||||||
import InputLabel from '@mui/material/InputLabel'
|
import InputLabel from '@mui/material/InputLabel'
|
||||||
import MenuItem from '@mui/material/MenuItem'
|
import MenuItem from '@mui/material/MenuItem'
|
||||||
@@ -20,6 +21,7 @@ import { OrderDetailContent } from '@/features/order-detail/ui/OrderDetailConten
|
|||||||
import { ORDER_STATUSES } from '@/shared/constants/order'
|
import { ORDER_STATUSES } from '@/shared/constants/order'
|
||||||
import { formatPriceRub } from '@/shared/lib/format-price'
|
import { formatPriceRub } from '@/shared/lib/format-price'
|
||||||
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
import { groupOrdersByStatus } from '@/shared/lib/group-orders-by-status'
|
||||||
|
import { orderRequiresPriceApproval } from '@/shared/lib/order-requires-price-approval'
|
||||||
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
|
import { ORDER_STATUS_MAP } from '@/shared/lib/order-status-data'
|
||||||
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
|
import { AdminDialog } from '@/shared/ui/AdminDialog/AdminDialog'
|
||||||
|
|
||||||
@@ -133,20 +135,39 @@ export function AdminOrdersPage() {
|
|||||||
{ORDER_STATUS_MAP[group.statusCode] ?? group.statusCode} ({group.items.length})
|
{ORDER_STATUS_MAP[group.statusCode] ?? group.statusCode} ({group.items.length})
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
{group.items.map((o) => (
|
{group.items.map((o) => {
|
||||||
<TableRow key={o.id} hover>
|
const knownStatus = ORDER_STATUSES.includes(o.status as (typeof ORDER_STATUSES)[number])
|
||||||
<TableCell>{o.id.slice(-8)}</TableCell>
|
const deliveryFeeLocked = (o as typeof o & { deliveryFeeLocked?: boolean }).deliveryFeeLocked ?? true
|
||||||
<TableCell>{o.user.email}</TableCell>
|
const showPriceApprovalChip =
|
||||||
<TableCell>{new Date(o.createdAt).toLocaleString('ru-RU')}</TableCell>
|
knownStatus &&
|
||||||
<TableCell>{formatPriceRub(o.totalCents)}</TableCell>
|
orderRequiresPriceApproval({
|
||||||
<TableCell>{o.itemsCount}</TableCell>
|
status: o.status as (typeof ORDER_STATUSES)[number],
|
||||||
<TableCell align="right">
|
deliveryType: o.deliveryType,
|
||||||
<Button size="small" onClick={() => open(o.id)}>
|
deliveryFeeLocked,
|
||||||
Открыть
|
})
|
||||||
</Button>
|
|
||||||
</TableCell>
|
return (
|
||||||
</TableRow>
|
<TableRow key={o.id} hover>
|
||||||
))}
|
<TableCell>
|
||||||
|
<Stack direction="row" spacing={1} useFlexGap sx={{ alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
|
<span>{o.id.slice(-8)}</span>
|
||||||
|
{showPriceApprovalChip && (
|
||||||
|
<Chip size="small" color="warning" variant="outlined" label="Цена не подтверждена" />
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</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>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
{ordersQuery.isSuccess && items.length === 0 && (
|
{ordersQuery.isSuccess && items.length === 0 && (
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { fetchAdminOrders } from '@/entities/order/api/admin-order-api'
|
||||||
|
import type { AdminOrderListItem } from '@/entities/order/api/admin-order-api'
|
||||||
|
import { AdminOrdersPage } from '../AdminOrdersPage'
|
||||||
|
|
||||||
|
vi.mock('@/entities/order/api/admin-order-api', () => ({
|
||||||
|
fetchAdminOrders: vi.fn(),
|
||||||
|
fetchAdminOrder: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const fetchAdminOrdersMock = vi.mocked(fetchAdminOrders)
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
retry: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<AdminOrdersPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AdminOrderListItemWithApproval = AdminOrderListItem & { deliveryFeeLocked?: boolean }
|
||||||
|
|
||||||
|
function createOrder(overrides?: Partial<AdminOrderListItemWithApproval>): AdminOrderListItemWithApproval {
|
||||||
|
return {
|
||||||
|
id: 'order-12345678',
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'delivery' as const,
|
||||||
|
deliveryCarrier: null,
|
||||||
|
paymentMethod: 'online' as const,
|
||||||
|
totalCents: 10000,
|
||||||
|
currency: 'RUB',
|
||||||
|
createdAt: '2026-05-28T10:00:00.000Z',
|
||||||
|
updatedAt: '2026-05-28T10:00:00.000Z',
|
||||||
|
user: { id: 'user-1', email: 'buyer@example.com' },
|
||||||
|
itemsCount: 1,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockOrdersResponse(order: AdminOrderListItemWithApproval) {
|
||||||
|
fetchAdminOrdersMock.mockResolvedValueOnce({
|
||||||
|
items: [order],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AdminOrdersPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('показывает бейдж для PENDING_PAYMENT + delivery + deliveryFeeLocked=false', async () => {
|
||||||
|
mockOrdersResponse(createOrder({ deliveryFeeLocked: false }))
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByText('Цена не подтверждена')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('12345678')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('не показывает бейдж для PENDING_PAYMENT + pickup + deliveryFeeLocked=false', async () => {
|
||||||
|
mockOrdersResponse(
|
||||||
|
createOrder({
|
||||||
|
id: 'order-87654321',
|
||||||
|
deliveryType: 'pickup',
|
||||||
|
deliveryFeeLocked: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByText('87654321')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Цена не подтверждена')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('не показывает бейдж для PAID + delivery + deliveryFeeLocked=false', async () => {
|
||||||
|
mockOrdersResponse(
|
||||||
|
createOrder({
|
||||||
|
id: 'order-45671234',
|
||||||
|
status: 'PAID',
|
||||||
|
deliveryFeeLocked: false,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByText('45671234')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Цена не подтверждена')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('не показывает бейдж при отсутствии deliveryFeeLocked', async () => {
|
||||||
|
mockOrdersResponse(
|
||||||
|
createOrder({
|
||||||
|
id: 'order-11223344',
|
||||||
|
deliveryFeeLocked: undefined,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
expect(await screen.findByText('11223344')).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Цена не подтверждена')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('вызывает fetchAdminOrders на стартовом рендере', async () => {
|
||||||
|
mockOrdersResponse(createOrder({ id: 'order-99887766', deliveryFeeLocked: true }))
|
||||||
|
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
await screen.findByText('99887766')
|
||||||
|
expect(fetchAdminOrdersMock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'
|
||||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'
|
||||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined'
|
||||||
@@ -218,14 +218,13 @@ export function AdminSliderPage() {
|
|||||||
|
|
||||||
const galleryItems: GalleryImageItem[] = galleryQuery.data?.items ?? []
|
const galleryItems: GalleryImageItem[] = galleryQuery.data?.items ?? []
|
||||||
|
|
||||||
const initialSlides = useMemo<SlideDraft[]>(() => {
|
const initialSlides: SlideDraft[] = sliderQuery.isSuccess
|
||||||
if (!sliderQuery.isSuccess) return []
|
? sliderQuery.data.slides.map((s) => ({
|
||||||
return sliderQuery.data.slides.map((s) => ({
|
galleryImageId: s.galleryImageId,
|
||||||
galleryImageId: s.galleryImageId,
|
caption: s.caption,
|
||||||
caption: s.caption,
|
textColor: s.textColor || '#ffffff',
|
||||||
textColor: s.textColor || '#ffffff',
|
}))
|
||||||
}))
|
: []
|
||||||
}, [sliderQuery.isSuccess, sliderQuery.data?.slides])
|
|
||||||
|
|
||||||
if (sliderQuery.isLoading || galleryQuery.isLoading) {
|
if (sliderQuery.isLoading || galleryQuery.isLoading) {
|
||||||
return <Typography color="text.secondary">Загрузка…</Typography>
|
return <Typography color="text.secondary">Загрузка…</Typography>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { orderRequiresPriceApproval } from '../order-requires-price-approval'
|
||||||
|
|
||||||
|
describe('orderRequiresPriceApproval', () => {
|
||||||
|
it('returns true when pending payment delivery fee is not locked for delivery', () => {
|
||||||
|
const result = orderRequiresPriceApproval({
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryFeeLocked: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when status is not pending payment', () => {
|
||||||
|
const result = orderRequiresPriceApproval({
|
||||||
|
status: 'PAID',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryFeeLocked: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when delivery type is pickup', () => {
|
||||||
|
const result = orderRequiresPriceApproval({
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'pickup',
|
||||||
|
deliveryFeeLocked: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when delivery fee is locked', () => {
|
||||||
|
const result = orderRequiresPriceApproval({
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryFeeLocked: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { OrderStatus } from '@/shared/constants/order'
|
||||||
|
|
||||||
|
type OrderPriceApprovalCandidate = {
|
||||||
|
status: OrderStatus
|
||||||
|
deliveryType: 'delivery' | 'pickup'
|
||||||
|
deliveryFeeLocked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderRequiresPriceApproval(order: OrderPriceApprovalCandidate): boolean {
|
||||||
|
return order.status === 'PENDING_PAYMENT' && order.deliveryType === 'delivery' && order.deliveryFeeLocked === false
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
# Admin Orders UX Improvements Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Добавить в списке заказов маркер `Цена не подтверждена` и заменить смену статуса в деталке на быстрые кнопки допустимых переходов.
|
||||||
|
|
||||||
|
**Architecture:** Изменения ограничены фронтендом (`client`) и опираются на текущие поля заказа (`status`, `deliveryType`, `deliveryFeeLocked`) и текущую логику переходов `getAdminNextOrderStatuses`. Серверные API и контракты не меняются, синхронизация данных остается через существующую инвалидацию React Query.
|
||||||
|
|
||||||
|
**Tech Stack:** React, TypeScript, MUI, TanStack React Query, Vitest, Testing Library.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Create:**
|
||||||
|
- `client/src/shared/lib/order-requires-price-approval.ts`
|
||||||
|
- `client/src/shared/lib/__tests__/order-requires-price-approval.test.ts`
|
||||||
|
- `client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
|
||||||
|
- `client/src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx`
|
||||||
|
|
||||||
|
**Modify:**
|
||||||
|
- `client/src/pages/admin-orders/ui/AdminOrdersPage.tsx`
|
||||||
|
- `client/src/features/order-detail/ui/OrderDetailContent.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Вычисление признака "цена не подтверждена"
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `client/src/shared/lib/order-requires-price-approval.ts`
|
||||||
|
- Test: `client/src/shared/lib/__tests__/order-requires-price-approval.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing unit test**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { orderRequiresPriceApproval } from '../order-requires-price-approval'
|
||||||
|
|
||||||
|
describe('orderRequiresPriceApproval', () => {
|
||||||
|
it('returns true for delivery pending payment with unlocked delivery fee', () => {
|
||||||
|
expect(
|
||||||
|
orderRequiresPriceApproval({
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryFeeLocked: false,
|
||||||
|
}),
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false when delivery fee is already locked', () => {
|
||||||
|
expect(
|
||||||
|
orderRequiresPriceApproval({
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryFeeLocked: true,
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for pickup even if payment is pending', () => {
|
||||||
|
expect(
|
||||||
|
orderRequiresPriceApproval({
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'pickup',
|
||||||
|
deliveryFeeLocked: false,
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns false for non-pending statuses', () => {
|
||||||
|
expect(
|
||||||
|
orderRequiresPriceApproval({
|
||||||
|
status: 'PAID',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryFeeLocked: false,
|
||||||
|
}),
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd client && npm test -- src/shared/lib/__tests__/order-requires-price-approval.test.ts`
|
||||||
|
Expected: FAIL with module/function not found.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write minimal implementation**
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type PriceApprovalOrder = {
|
||||||
|
status: string
|
||||||
|
deliveryType: 'delivery' | 'pickup'
|
||||||
|
deliveryFeeLocked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orderRequiresPriceApproval(order: PriceApprovalOrder): boolean {
|
||||||
|
return order.status === 'PENDING_PAYMENT' && order.deliveryType === 'delivery' && order.deliveryFeeLocked === false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `cd client && npm test -- src/shared/lib/__tests__/order-requires-price-approval.test.ts`
|
||||||
|
Expected: PASS (4 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/shared/lib/order-requires-price-approval.ts client/src/shared/lib/__tests__/order-requires-price-approval.test.ts
|
||||||
|
git commit -m "test: add price approval predicate for admin orders"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Маркер "Цена не подтверждена" в списке заказов
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/pages/admin-orders/ui/AdminOrdersPage.tsx`
|
||||||
|
- Test: `client/src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing component test for chip visibility**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { AdminOrdersPage } from '../AdminOrdersPage'
|
||||||
|
|
||||||
|
const fetchAdminOrdersMock = vi.fn()
|
||||||
|
|
||||||
|
vi.mock('@/entities/order/api/admin-order-api', () => ({
|
||||||
|
fetchAdminOrders: fetchAdminOrdersMock,
|
||||||
|
fetchAdminOrder: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('AdminOrdersPage price approval marker', () => {
|
||||||
|
it('shows "Цена не подтверждена" for eligible order', async () => {
|
||||||
|
fetchAdminOrdersMock.mockResolvedValueOnce({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'order-1',
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryFeeLocked: false,
|
||||||
|
totalCents: 10000,
|
||||||
|
currency: 'RUB',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
user: { id: 'u1', email: 'a@example.com' },
|
||||||
|
itemsCount: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const qc = new QueryClient()
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<AdminOrdersPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByText('Цена не подтверждена')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd client && npm test -- src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx`
|
||||||
|
Expected: FAIL because chip text is not rendered yet.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement marker in `AdminOrdersPage`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import Chip from '@mui/material/Chip'
|
||||||
|
import { orderRequiresPriceApproval } from '@/shared/lib/order-requires-price-approval'
|
||||||
|
|
||||||
|
// ...
|
||||||
|
|
||||||
|
{group.items.map((o) => {
|
||||||
|
const needsPriceApproval = orderRequiresPriceApproval({
|
||||||
|
status: o.status,
|
||||||
|
deliveryType: o.deliveryType,
|
||||||
|
deliveryFeeLocked: o.deliveryFeeLocked,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={o.id} hover>
|
||||||
|
<TableCell>
|
||||||
|
<Stack direction="row" spacing={1} alignItems="center">
|
||||||
|
<Box component="span">{o.id.slice(-8)}</Box>
|
||||||
|
{needsPriceApproval && <Chip size="small" color="warning" label="Цена не подтверждена" />}
|
||||||
|
</Stack>
|
||||||
|
</TableCell>
|
||||||
|
{/* ... */}
|
||||||
|
</TableRow>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add negative case and rerun test**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
it('does not show marker for non-eligible order', async () => {
|
||||||
|
fetchAdminOrdersMock.mockResolvedValueOnce({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'order-2',
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryFeeLocked: true,
|
||||||
|
totalCents: 10000,
|
||||||
|
currency: 'RUB',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
user: { id: 'u2', email: 'b@example.com' },
|
||||||
|
itemsCount: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
const qc = new QueryClient()
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<AdminOrdersPage />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await screen.findByText('order-2'.slice(-8))).toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('Цена не подтверждена')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `cd client && npm test -- src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/pages/admin-orders/ui/AdminOrdersPage.tsx client/src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx
|
||||||
|
git commit -m "feat: show price approval marker in admin orders list"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Быстрые кнопки смены статуса в деталке
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `client/src/features/order-detail/ui/OrderDetailContent.tsx`
|
||||||
|
- Test: `client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests for quick actions**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { OrderDetailContent } from '../OrderDetailContent'
|
||||||
|
import type { AdminOrderDetailResponse } from '@/entities/order/api/admin-order-api'
|
||||||
|
|
||||||
|
const setAdminOrderStatusMock = vi.fn(async () => undefined)
|
||||||
|
|
||||||
|
vi.mock('@/entities/order/api/admin-order-api', async () => {
|
||||||
|
const actual = await vi.importActual<object>('@/entities/order/api/admin-order-api')
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
setAdminOrderStatus: setAdminOrderStatusMock,
|
||||||
|
postAdminOrderMessage: vi.fn(async () => undefined),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function buildDetail(patch: Partial<AdminOrderDetailResponse['item']>): AdminOrderDetailResponse['item'] {
|
||||||
|
return {
|
||||||
|
id: 'o1',
|
||||||
|
status: 'PENDING_PAYMENT',
|
||||||
|
deliveryType: 'delivery',
|
||||||
|
deliveryCarrier: null,
|
||||||
|
paymentMethod: 'online',
|
||||||
|
itemsSubtotalCents: 10000,
|
||||||
|
deliveryFeeCents: 500,
|
||||||
|
deliveryFeeLocked: false,
|
||||||
|
totalCents: 10500,
|
||||||
|
currency: 'RUB',
|
||||||
|
addressSnapshotJson: null,
|
||||||
|
comment: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
user: {
|
||||||
|
id: 'u1',
|
||||||
|
email: 'a@example.com',
|
||||||
|
displayName: null,
|
||||||
|
avatar: null,
|
||||||
|
avatarStyle: null,
|
||||||
|
},
|
||||||
|
items: [],
|
||||||
|
messages: [],
|
||||||
|
...patch,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OrderDetailContent quick status actions', () => {
|
||||||
|
it('renders quick action buttons for next statuses', () => {
|
||||||
|
const qc = new QueryClient()
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<OrderDetailContent detail={buildDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' })} orderId="o1" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: /Оплачен/i })).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('calls setAdminOrderStatus on click', () => {
|
||||||
|
const qc = new QueryClient()
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<OrderDetailContent detail={buildDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' })} orderId="o1" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /Оплачен/i }))
|
||||||
|
expect(setAdminOrderStatusMock).toHaveBeenCalledWith('o1', 'PAID')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `cd client && npm test -- src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
|
||||||
|
Expected: FAIL because old `Select` UI is still used.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Replace select with quick action buttons**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<Stack spacing={1}>
|
||||||
|
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
|
||||||
|
Быстрый переход статуса
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
{nextStatuses.length === 0 ? (
|
||||||
|
<Typography variant="body2" color="text.secondary">
|
||||||
|
Статус финальный, смена недоступна
|
||||||
|
</Typography>
|
||||||
|
) : (
|
||||||
|
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={1}>
|
||||||
|
{nextStatuses.map((nextStatus) => {
|
||||||
|
const isCancel = nextStatus === 'CANCELLED'
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={nextStatus}
|
||||||
|
variant={isCancel ? 'outlined' : 'contained'}
|
||||||
|
color={isCancel ? 'error' : 'primary'}
|
||||||
|
onClick={() => statusMut.mutate(nextStatus)}
|
||||||
|
disabled={statusMut.isPending}
|
||||||
|
>
|
||||||
|
{ORDER_STATUS_MAP[nextStatus] ?? nextStatus}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add pending/empty-state assertions and rerun tests**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
it('disables all quick action buttons while mutation is pending', () => {
|
||||||
|
const qc = new QueryClient()
|
||||||
|
setAdminOrderStatusMock.mockImplementationOnce(() => new Promise(() => {}))
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<OrderDetailContent detail={buildDetail({ status: 'PENDING_PAYMENT', deliveryType: 'delivery' })} orderId="o1" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const paidButton = screen.getByRole('button', { name: /Оплачен/i })
|
||||||
|
fireEvent.click(paidButton)
|
||||||
|
expect(paidButton).toBeDisabled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows final state note when no transitions available', () => {
|
||||||
|
const qc = new QueryClient()
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<OrderDetailContent detail={buildDetail({ status: 'DONE', deliveryType: 'delivery' })} orderId="o1" />
|
||||||
|
</QueryClientProvider>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Статус финальный, смена недоступна')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Run: `cd client && npm test -- src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/features/order-detail/ui/OrderDetailContent.tsx client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx
|
||||||
|
git commit -m "feat: replace admin order status select with quick actions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Регрессия, линт и финальная проверка
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify (if needed): `client/src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx`
|
||||||
|
- Modify (if needed): `client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
|
||||||
|
- Modify (if needed): `client/src/shared/lib/__tests__/order-requires-price-approval.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused test suite for changed units**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
`cd client && npm test -- src/shared/lib/__tests__/order-requires-price-approval.test.ts src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run frontend lint**
|
||||||
|
|
||||||
|
Run: `cd client && npm run lint`
|
||||||
|
Expected: PASS with no new lint errors.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run format check**
|
||||||
|
|
||||||
|
Run: `cd client && npm run format:check`
|
||||||
|
Expected: PASS, no formatting violations.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Fix issues if any and re-run exact failed command**
|
||||||
|
|
||||||
|
Run (example): `cd client && npm run lint`
|
||||||
|
Expected: PASS after fixes.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Final commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add client/src/pages/admin-orders/ui/AdminOrdersPage.tsx client/src/features/order-detail/ui/OrderDetailContent.tsx client/src/shared/lib/order-requires-price-approval.ts client/src/shared/lib/__tests__/order-requires-price-approval.test.ts client/src/pages/admin-orders/ui/__tests__/AdminOrdersPage.test.tsx client/src/features/order-detail/ui/__tests__/OrderDetailContent.test.tsx
|
||||||
|
git commit -m "feat: improve admin orders flow for price approval and status updates"
|
||||||
|
```
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Дизайн: Доработки админки заказов (маркер цены + быстрые статусы)
|
||||||
|
|
||||||
|
**Дата:** 2026-05-28
|
||||||
|
**Статус:** Draft
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Нужно улучшить UX в админке заказов по двум направлениям:
|
||||||
|
|
||||||
|
1. Явно показывать в списке заказов, что требуется подтверждение цены (итоговой стоимости для оплаты).
|
||||||
|
2. Сделать смену статуса заказа более удобной для администратора.
|
||||||
|
|
||||||
|
Ограничения, согласованные в обсуждении:
|
||||||
|
|
||||||
|
- Не вводим новый статус заказа для подтверждения цены.
|
||||||
|
- Используем короткий текст для индикатора.
|
||||||
|
- Для смены статуса выбираем формат быстрых кнопок доступных переходов (вместо выпадающего списка).
|
||||||
|
|
||||||
|
## Цели
|
||||||
|
|
||||||
|
- Сократить время на обнаружение заказов, требующих подтверждения цены.
|
||||||
|
- Упростить и ускорить смену статуса до 1 клика.
|
||||||
|
- Сохранить существующие API-контракты и логику допустимых переходов.
|
||||||
|
|
||||||
|
## Не в рамках этой итерации
|
||||||
|
|
||||||
|
- Изменение серверной статусной модели.
|
||||||
|
- Добавление новых серверных полей или эндпоинтов.
|
||||||
|
- Массовая смена статусов из таблицы без открытия деталки.
|
||||||
|
- Редизайн всей таблицы заказов.
|
||||||
|
|
||||||
|
## Решение (утвержденный вариант A)
|
||||||
|
|
||||||
|
### 1) Маркер в списке заказов
|
||||||
|
|
||||||
|
В `client/src/pages/admin-orders/ui/AdminOrdersPage.tsx` добавить короткий бейдж `Цена не подтверждена` для строк, где:
|
||||||
|
|
||||||
|
- `status === 'PENDING_PAYMENT'`
|
||||||
|
- `deliveryType === 'delivery'`
|
||||||
|
- `deliveryFeeLocked === false`
|
||||||
|
|
||||||
|
Размещение: в колонке `ID` рядом с коротким номером заказа (`o.id.slice(-8)`), чтобы не расширять таблицу и чтобы сигнал был виден до открытия деталки.
|
||||||
|
|
||||||
|
### 2) Быстрая смена статуса в деталке заказа
|
||||||
|
|
||||||
|
В `client/src/features/order-detail/ui/OrderDetailContent.tsx` заменить текущий `Select` "Сменить статус" на набор кнопок быстрых переходов:
|
||||||
|
|
||||||
|
- Источник переходов остается прежним: `getAdminNextOrderStatuses(detail.status, detail.deliveryType)`
|
||||||
|
- Для каждого допустимого статуса рендерится отдельная кнопка
|
||||||
|
- Клик вызывает текущую мутацию `setAdminOrderStatus` через `statusMut.mutate(next)`
|
||||||
|
|
||||||
|
Поведение кнопок:
|
||||||
|
|
||||||
|
- Пока мутация выполняется (`statusMut.isPending`) все кнопки отключены
|
||||||
|
- Если доступных переходов нет — показываем текст `Статус финальный, смена недоступна`
|
||||||
|
- Для `CANCELLED` использовать визуально менее акцентную кнопку (`outlined`), чтобы снизить риск случайного нажатия
|
||||||
|
|
||||||
|
## Архитектурные границы и переиспользование
|
||||||
|
|
||||||
|
- Используем существующие данные `deliveryFeeLocked`, `status`, `deliveryType`.
|
||||||
|
- Правила переходов не дублируем в UI, берем из `getAdminNextOrderStatuses`.
|
||||||
|
- API слой (`client/src/entities/order/api/admin-order-api.ts`) без изменений.
|
||||||
|
- React Query инвалидация сохраняется текущая:
|
||||||
|
- `['admin', 'orders']`
|
||||||
|
- `['admin', 'orders', 'detail']`
|
||||||
|
- `['admin', 'orders', 'summary']`
|
||||||
|
|
||||||
|
## Поток данных
|
||||||
|
|
||||||
|
1. Страница списка получает `items` из `fetchAdminOrders`.
|
||||||
|
2. Для каждой строки вычисляется `needsPriceApproval`.
|
||||||
|
3. Если `needsPriceApproval === true`, отображается бейдж `Цена не подтверждена`.
|
||||||
|
4. В деталке рассчитывается `nextStatuses`.
|
||||||
|
5. Клик по кнопке статуса вызывает `statusMut`.
|
||||||
|
6. После успеха инвалидация обновляет список/деталку/summary; маркер в списке исчезает автоматически, когда условие перестает выполняться.
|
||||||
|
|
||||||
|
## Обработка ошибок
|
||||||
|
|
||||||
|
- Ошибка смены статуса: показать локальный `Alert` в деталке с текстом `Не удалось сменить статус`, дать возможность повторить действие.
|
||||||
|
- Ошибка загрузки списка: сохранить текущее поведение (`Не удалось загрузить заказы.`).
|
||||||
|
- Пустой список переходов: показать явное сообщение о финальном статусе.
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
Минимальный набор:
|
||||||
|
|
||||||
|
1. Unit: проверка условия `needsPriceApproval`.
|
||||||
|
2. Component (`OrderDetailContent`):
|
||||||
|
- рендерит кнопки только допустимых переходов
|
||||||
|
- вызывает `setAdminOrderStatus` при клике
|
||||||
|
- блокирует кнопки в pending-состоянии
|
||||||
|
3. Component/Smoke (`AdminOrdersPage`):
|
||||||
|
- показывает `Цена не подтверждена` для целевых заказов
|
||||||
|
- не показывает бейдж для остальных
|
||||||
|
|
||||||
|
## Критерии готовности
|
||||||
|
|
||||||
|
- В списке заказов видно короткий маркер `Цена не подтверждена` для релевантных заказов.
|
||||||
|
- В деталке смена статуса выполняется через быстрые кнопки допустимых переходов.
|
||||||
|
- Список и деталка синхронизируются после успешной смены статуса.
|
||||||
|
- Для `CANCELLED` используется менее акцентный стиль кнопки.
|
||||||
|
- Проходят `npm run lint` и `npm run format:check` в `client`.
|
||||||
|
|
||||||
|
## План изменений по файлам
|
||||||
|
|
||||||
|
- `client/src/pages/admin-orders/ui/AdminOrdersPage.tsx` — маркер `Цена не подтверждена` в строках списка.
|
||||||
|
- `client/src/features/order-detail/ui/OrderDetailContent.tsx` — замена select на быстрые кнопки.
|
||||||
|
- (опционально) `client/src/shared/lib/` — небольшой хелпер `needsPriceApproval`, если нужно переиспользование или unit-тест вне компонента.
|
||||||
|
|
||||||
|
## Риски и меры
|
||||||
|
|
||||||
|
- Риск перегруза UI в деталке при большом количестве кнопок: низкий, так как число допустимых переходов обычно 1-2.
|
||||||
|
- Риск случайного нажатия на отмену: снижается стилем `outlined` для `CANCELLED`.
|
||||||
|
- Риск расхождения логики переходов: отсутствует, т.к. используется единый источник `getAdminNextOrderStatuses`.
|
||||||
Binary file not shown.
Reference in New Issue
Block a user