This commit is contained in:
Kirill
2026-05-28 21:20:35 +05:00
parent 966731d3e1
commit 7000fbffa7
10 changed files with 993 additions and 53 deletions
@@ -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`.