feat: simplify delivery roles and docs
This commit is contained in:
parent
c28c826601
commit
a5113a2ed5
11
README.md
11
README.md
|
|
@ -1,6 +1,6 @@
|
||||||
# Construction Delivery Control
|
# Construction Delivery Control
|
||||||
|
|
||||||
React-приложение для управления заказами, доставкой, ролями сотрудников и публичным согласованием доставки с клиентом.
|
React-приложение для управления доставкой заказов. В текущем контуре есть три внутренние роли и публичная страница клиента: менеджер, логист, водитель и клиент.
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
|
|
@ -16,11 +16,12 @@ npm run dev
|
||||||
## Что уже есть
|
## Что уже есть
|
||||||
|
|
||||||
- OTP-вход по email через Supabase Auth.
|
- OTP-вход по email через Supabase Auth.
|
||||||
- Role-based dashboard для менеджера, логиста, водителя и администратора.
|
- Служебный вход `roles@local` для демонстрации ролей менеджера, логиста и водителя.
|
||||||
- Карточка заказа с историей, чатом и слотами доставки.
|
- Role-based dashboard для менеджера, логиста и водителя.
|
||||||
- Публичная страница `/delivery/:token` для выбора даты и половины дня доставки.
|
- Карточка заказа с составом, комментариями и историей.
|
||||||
|
- Публичная страница `/delivery/:token` для выбора даты, половины дня и просмотра состава заказа.
|
||||||
- Supabase SQL-схема, таблицы приглашений и Edge Functions для invitation flow.
|
- Supabase SQL-схема, таблицы приглашений и Edge Functions для invitation flow.
|
||||||
- Документация по архитектуре, сценариям и интеграциям.
|
- Документация по продукту, архитектуре и сценариям.
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,76 +1,64 @@
|
||||||
# SuperSam: Обзор системы
|
# SuperSam: Обзор системы
|
||||||
|
|
||||||
`SuperSam` — это система управления доставкой заказов, которая объединяет внутренние рабочие кабинеты сотрудников и публичную страницу для клиента. Приложение помогает пройти путь от готовности заказа к отгрузке до согласования доставки, назначения исполнителей, фиксации результата и контроля исключений.
|
`SuperSam` — это система управления доставкой заказов. В текущем scope она состоит из трёх внутренних ролей и одной публичной страницы для клиента: менеджер, логист, водитель и клиент.
|
||||||
|
|
||||||
## Задачи приложения
|
## Задачи приложения
|
||||||
|
|
||||||
- собрать в одном месте информацию по заказам, доставке, истории действий и коммуникациям;
|
- показать менеджеру единый реестр доставочных заказов с поиском и карточкой заказа;
|
||||||
- разделить рабочие зоны по ролям, чтобы каждый сотрудник видел только свой контур задач;
|
- показать логисту список доставок на сегодня и ближайшие дни с половинами дня;
|
||||||
- дать логисту инструмент для запуска и контроля согласования доставки;
|
- показать водителю свои доставки, адрес, состав заказа и базовые статусы;
|
||||||
- дать клиенту простую ссылку, по которой он может выбрать дату и половину дня доставки;
|
- дать клиенту публичную ссылку, по которой он выбирает дату и половину дня доставки;
|
||||||
- сохранить в Supabase историю изменений, статусы и интеграционные события.
|
- хранить состояние заказов, приглашений и истории изменений в Supabase.
|
||||||
|
|
||||||
## Роли
|
## Роли
|
||||||
|
|
||||||
### Менеджер
|
### Менеджер
|
||||||
|
|
||||||
Менеджер работает с заказами на ранних этапах:
|
- видит список заказов доставки;
|
||||||
- видит список заказов и карточки клиентов;
|
- ищет по номеру заказа, клиенту и телефону;
|
||||||
- следит за составом заказа и комментариями;
|
- открывает карточку заказа и смотрит состав, комментарии и историю;
|
||||||
- передаёт заказ дальше по процессу после подтверждения.
|
- не работает с созданием заказов и внутренними служебными экранами.
|
||||||
|
|
||||||
### Логист
|
### Логист
|
||||||
|
|
||||||
Логист отвечает за доставку:
|
- видит заказы, готовые к доставке;
|
||||||
- видит готовые к запуску и проблемные заказы;
|
- смотрит ближайшие даты: сегодня, завтра и послезавтра;
|
||||||
- контролирует статусы согласования доставки;
|
- смотрит половину дня и текущий статус доставки;
|
||||||
- назначает и корректирует слоты;
|
- открывает карточку заказа, чтобы свериться с деталями.
|
||||||
- переводит заказ в ручную обработку, если клиент не ответил;
|
|
||||||
- отслеживает историю и связанные сообщения.
|
|
||||||
|
|
||||||
### Водитель
|
### Водитель
|
||||||
|
|
||||||
Водитель работает только со своими доставками:
|
- видит только свои доставки;
|
||||||
- видит назначенные маршруты;
|
- открывает адрес, клиента, состав заказа и комментарии;
|
||||||
- открывает карточку точки доставки;
|
- меняет базовый статус доставки по маршруту.
|
||||||
- фиксирует ход доставки и итоговый статус.
|
|
||||||
|
|
||||||
### Администратор
|
|
||||||
|
|
||||||
Администратор видит всю систему:
|
|
||||||
- пользователей и роли;
|
|
||||||
- общие списки заказов и событий;
|
|
||||||
- состояние интеграций и служебные данные.
|
|
||||||
|
|
||||||
### Клиент
|
### Клиент
|
||||||
|
|
||||||
Клиент не входит во внутренний кабинет. Он получает публичную ссылку вида `/delivery/:token` и по ней:
|
- получает публичную ссылку вида `/delivery/:token`;
|
||||||
- видит номер заказа;
|
- видит номер заказа и состав заказа;
|
||||||
- выбирает удобную дату;
|
- выбирает дату и половину дня: `До обеда` или `После обеда`;
|
||||||
- выбирает половину дня: `До обеда` или `После обеда`;
|
- подтверждает выбор без входа во внутренний кабинет.
|
||||||
- подтверждает выбор.
|
|
||||||
|
|
||||||
## Основные сценарии
|
## Основные сценарии
|
||||||
|
|
||||||
### Внутренний сценарий
|
### Внутренний сценарий
|
||||||
|
|
||||||
1. Заказ попадает в систему.
|
1. Заказ появляется в Supabase.
|
||||||
2. Менеджер и внутренние сотрудники ведут заказ по этапам.
|
2. Менеджер видит его в реестре и сверяет состав.
|
||||||
3. Когда заказ готов к доставке, логист запускает приглашение клиенту.
|
3. Логист отслеживает готовность и ближайшее окно доставки.
|
||||||
4. Клиент выбирает слот по публичной ссылке.
|
4. Водитель получает свою доставку и доводит её до результата.
|
||||||
5. Система переводит заказ в `Доставка согласована`.
|
|
||||||
6. Логист и водитель доводят доставку до результата.
|
|
||||||
|
|
||||||
### Сценарий клиента
|
### Сценарий клиента
|
||||||
|
|
||||||
Клиентская страница работает по token из таблицы `public.delivery_invitations`. Для рабочего показа используется заранее загруженный seed-набор данных.
|
Клиентская страница работает по token из таблицы `public.delivery_invitations`.
|
||||||
|
|
||||||
После загрузки seed можно открыть ссылку:
|
После загрузки seed можно открыть ссылку:
|
||||||
|
|
||||||
`/delivery/client-flow-1001`
|
`/delivery/client-flow-1001`
|
||||||
|
|
||||||
Эта ссылка должна показывать:
|
Эта ссылка показывает:
|
||||||
- заказ `CD-240031`;
|
- заказ `CD-240031`;
|
||||||
|
- состав заказа;
|
||||||
- четыре варианта слота;
|
- четыре варианта слота;
|
||||||
- две даты;
|
- две даты;
|
||||||
- две половины дня: `До обеда` и `После обеда`.
|
- две половины дня: `До обеда` и `После обеда`.
|
||||||
|
|
@ -83,8 +71,6 @@
|
||||||
|
|
||||||
## Что хранится в Supabase
|
## Что хранится в Supabase
|
||||||
|
|
||||||
### Основные таблицы
|
|
||||||
|
|
||||||
- `public.users` — пользователи и роли;
|
- `public.users` — пользователи и роли;
|
||||||
- `public.orders` — заказы и текущие статусы;
|
- `public.orders` — заказы и текущие статусы;
|
||||||
- `public.order_history` — история изменений;
|
- `public.order_history` — история изменений;
|
||||||
|
|
@ -92,34 +78,24 @@
|
||||||
- `public.delivery_invitations` — публичные invitation token и состояние клиентского flow;
|
- `public.delivery_invitations` — публичные invitation token и состояние клиентского flow;
|
||||||
- `public.integration_events` — технические и интеграционные события.
|
- `public.integration_events` — технические и интеграционные события.
|
||||||
|
|
||||||
### Важные поля для клиентского flow
|
|
||||||
|
|
||||||
- `delivery_invitations.token_hash` — хеш публичного токена;
|
|
||||||
- `delivery_invitations.state` — состояние приглашения;
|
|
||||||
- `delivery_invitations.available_slots` — список доступных вариантов для клиента;
|
|
||||||
- `delivery_invitations.delivery_date` и `delivery_invitations.delivery_time` — выбранный или основной слот;
|
|
||||||
- `orders.status` — текущий рабочий статус заказа;
|
|
||||||
- `orders.delivery_agreement_status` — статус согласования доставки.
|
|
||||||
|
|
||||||
## Как подготовить систему к показу
|
## Как подготовить систему к показу
|
||||||
|
|
||||||
1. Загрузить схему `supabase/schema.sql`.
|
1. Загрузить схему `supabase/schema.sql`.
|
||||||
2. Создать нужных пользователей в `auth.users`.
|
2. Выполнить `supabase/seed/stage-1-demo.sql`.
|
||||||
3. Выполнить `supabase/seed/stage-1-demo.sql`.
|
3. Убедиться, что развернуты Edge Functions:
|
||||||
4. Убедиться, что Edge Functions развернуты:
|
|
||||||
- `get-delivery-invitation`
|
- `get-delivery-invitation`
|
||||||
- `confirm-delivery-choice`
|
- `confirm-delivery-choice`
|
||||||
- `create-delivery-invitation`
|
- `create-delivery-invitation`
|
||||||
5. Открыть внутренний кабинет.
|
4. Открыть внутренний кабинет и пройти вход под ролью.
|
||||||
6. Открыть клиентскую ссылку `/delivery/client-flow-1001`.
|
5. Открыть клиентскую ссылку `/delivery/client-flow-1001`.
|
||||||
|
|
||||||
## Что показывать на встрече
|
## Что показывать на встрече
|
||||||
|
|
||||||
- вход во внутренний кабинет;
|
- вход менеджера, логиста и водителя;
|
||||||
- список заказов для менеджера, логиста и водителя;
|
- реестр заказов и карточку заказа;
|
||||||
- карточку заказа и статусы;
|
- список доставок по датам для логиста;
|
||||||
- клиентскую ссылку с выбором даты и половины дня;
|
- карточку доставки водителя;
|
||||||
- изменение статуса заказа после подтверждения клиентом.
|
- клиентскую ссылку с выбором даты и половины дня.
|
||||||
|
|
||||||
## Полезные документы
|
## Полезные документы
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
# Role Focused Delivery Simplification Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Упростить продукт до рабочего delivery-контура из 4 ролей: менеджер, логист, водитель и клиент, убрав лишние разделы, действия и экраны, которые не входят в согласованный scope.
|
||||||
|
|
||||||
|
**Architecture:** Текущий `DashboardPage` работает как широкий универсальный центр управления заказами. В рамках этого плана он сужается до трёх role-specific рабочих зон: менеджер видит только реестр и поиск, логист видит готовность и план доставки по датам/половине дня, водитель видит свои доставки и состав заказов. Клиентский route `/delivery/:token` дополняется составом заказа и остаётся отдельным публичным экраном.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, Vite, existing UI kit, Supabase-backed orders/invitations, Vitest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify: `src/pages/DashboardPage.jsx` — убрать лишние секции (`production`, `admin`, `references`, избыточные tabs/orders views) и собрать role-focused рендеринг.
|
||||||
|
- Modify: `src/hooks/useOrders.js` — при необходимости сузить возвращаемые данные и убрать неиспользуемые callbacks.
|
||||||
|
- Modify: `src/components/dashboard/RoleWorkspacePanel.jsx` — переписать описания ролей под итоговый scope.
|
||||||
|
- Modify: `src/components/orders/OrderFilters.jsx` — оставить фильтры, которые реально нужны менеджеру/логисту.
|
||||||
|
- Modify: `src/components/orders/OrdersTable.jsx` — ориентировать таблицу на заказ доставки, номер, клиента, дату доставки и статус.
|
||||||
|
- Modify: `src/components/orders/OrderDetailPanel.jsx` — оставить просмотр деталей, состава заказа и базового статуса, без лишних командных функций.
|
||||||
|
- Delete or stop using: `src/components/orders/OrderEditorPanel.jsx` — создание/редактирование заказов должно уйти из продукта.
|
||||||
|
- Delete or stop using: `src/components/orders/OrdersKanbanBoard.jsx`
|
||||||
|
- Delete or stop using: `src/components/orders/OrdersCalendarView.jsx`
|
||||||
|
- Delete or stop using: `src/components/admin/AuditPanel.jsx`
|
||||||
|
- Delete or stop using: `src/components/admin/UserOnboardingPanel.jsx`
|
||||||
|
- Delete or stop using: `src/components/dashboard/ProductionQueuePanel.jsx`
|
||||||
|
- Modify or replace: `src/components/logistics/BotControlPanel.jsx` — вместо “управления ботами” сделать компактную панель логиста по доставке.
|
||||||
|
- Modify or replace: `src/components/logistics/DeliverySetDetailPanel.jsx` — если оставить, то только как простой delivery detail без производственного слоя; иначе убрать из сценария.
|
||||||
|
- Modify: `src/components/driver/DriverDeliveryPlanner.jsx` — упростить до списка доставок без kanban и reorder.
|
||||||
|
- Modify: `src/components/driver/DriverDeliveryDetail.jsx` — оставить адрес, состав заказа, слот доставки и минимальные действия водителя.
|
||||||
|
- Modify: `src/pages/ClientDeliveryPage.jsx` — показать клиенту состав заказа и информацию по заказу вместе с выбором даты/половины дня.
|
||||||
|
- Modify: `src/components/client/DeliveryChoiceFlow.jsx` — упростить copy и добавить summary по заказу.
|
||||||
|
- Modify: `src/components/client/DeliveryStateNotice.jsx` — оставить только states, которые нужны в реальном клиентском процессе.
|
||||||
|
- Modify: `src/services/supabase/orderRepository.js` — убедиться, что клиентская и role-specific UI берут состав заказа и доставочные поля из реальных данных.
|
||||||
|
- Modify: `docs/product-overview.md` — синхронизировать документ с новым узким scope.
|
||||||
|
- Modify: `README.md` — коротко обновить позиционирование и ссылку на новый scope.
|
||||||
|
|
||||||
|
## Chunk 1: Scope Guard In Tests
|
||||||
|
|
||||||
|
### Task 1: Зафиксировать тестами новый role-focused scope
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/orders/OrderFilters.test.jsx`
|
||||||
|
- Modify or Create: `src/pages/DashboardPage.test.jsx`
|
||||||
|
- Modify or Create: role-specific component tests
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing tests for manager/logistician/driver visible sections**
|
||||||
|
|
||||||
|
Покрыть:
|
||||||
|
- менеджер видит только список заказов, поиск и карточку заказа;
|
||||||
|
- логист видит только список готовых/запланированных доставок;
|
||||||
|
- водитель видит только свои доставки и состав заказа;
|
||||||
|
- лишние секции (`production`, `admin`, `references`, `kanban`, `calendar`, `archive`, `history`) не рендерятся.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run targeted tests to verify current mismatch**
|
||||||
|
|
||||||
|
Run: `npm test -- src/components/orders/OrderFilters.test.jsx src/pages/DashboardPage.test.jsx`
|
||||||
|
Expected: FAIL на текущем широком dashboard.
|
||||||
|
|
||||||
|
### Task 2: Зафиксировать тестом клиентский заказный summary
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/client/DeliveryChoiceFlow.test.jsx`
|
||||||
|
- Modify or Create: `src/pages/ClientDeliveryPage.test.js`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing expectation for client order contents**
|
||||||
|
|
||||||
|
Покрыть:
|
||||||
|
- номер заказа;
|
||||||
|
- состав заказа;
|
||||||
|
- выбор даты и половины дня.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run targeted client tests and confirm failure**
|
||||||
|
|
||||||
|
Run: `npm test -- src/components/client/DeliveryChoiceFlow.test.jsx src/pages/ClientDeliveryPage.test.js`
|
||||||
|
Expected: FAIL
|
||||||
|
|
||||||
|
## Chunk 2: Simplify Dashboard By Role
|
||||||
|
|
||||||
|
### Task 3: Сузить навигацию и секции dashboard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/DashboardPage.jsx`
|
||||||
|
- Modify: `src/components/dashboard/RoleWorkspacePanel.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove non-scope nav sections**
|
||||||
|
|
||||||
|
Убрать:
|
||||||
|
- `production`
|
||||||
|
- `admin`
|
||||||
|
- `references`
|
||||||
|
|
||||||
|
И свести менеджера/логиста к простым рабочим секциям без обзорного “комбайна”.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove extra order tabs**
|
||||||
|
|
||||||
|
Для менеджера оставить только основной список заказов.
|
||||||
|
|
||||||
|
Убрать из пользовательского сценария:
|
||||||
|
- `calendar`
|
||||||
|
- `kanban`
|
||||||
|
- `history`
|
||||||
|
- `archive`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-run dashboard tests**
|
||||||
|
|
||||||
|
Run: `npm test -- src/pages/DashboardPage.test.jsx`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 4: Упростить экран менеджера
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/orders/OrderFilters.jsx`
|
||||||
|
- Modify: `src/components/orders/OrdersTable.jsx`
|
||||||
|
- Modify: `src/components/orders/OrderDetailPanel.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Keep only filters manager really needs**
|
||||||
|
|
||||||
|
Оставить:
|
||||||
|
- поиск по номеру заказа;
|
||||||
|
- при необходимости телефон/клиент;
|
||||||
|
- статус доставки.
|
||||||
|
|
||||||
|
Убрать лишние фильтры и продуктовую лексику, которая больше не используется.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Keep order details read-first**
|
||||||
|
|
||||||
|
В карточке заказа оставить:
|
||||||
|
- номер;
|
||||||
|
- клиент;
|
||||||
|
- адрес;
|
||||||
|
- состав;
|
||||||
|
- дата доставки;
|
||||||
|
- половина дня;
|
||||||
|
- текущий статус.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Remove create/edit order entry points**
|
||||||
|
|
||||||
|
Убрать из пользовательского flow любые кнопки и панели создания/редактирования заказов.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Re-run manager-facing tests**
|
||||||
|
|
||||||
|
Run: `npm test -- src/components/orders/OrdersTable.test.jsx src/components/orders/OrderDetailPanel.test.jsx src/components/orders/OrderFilters.test.jsx`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 5: Упростить экран логиста
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/DashboardPage.jsx`
|
||||||
|
- Modify or Replace: `src/components/logistics/BotControlPanel.jsx`
|
||||||
|
- Modify or stop using: `src/components/logistics/DeliverySetDetailPanel.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace logistics board with practical delivery list**
|
||||||
|
|
||||||
|
Логист должен видеть:
|
||||||
|
- заказы, готовые на сегодня;
|
||||||
|
- доставки, запланированные на завтра/послезавтра;
|
||||||
|
- половину дня;
|
||||||
|
- текущий статус.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Keep only logistics actions that are in scope**
|
||||||
|
|
||||||
|
Оставить:
|
||||||
|
- просмотр заказа;
|
||||||
|
- фиксацию/изменение даты;
|
||||||
|
- фиксацию половины дня;
|
||||||
|
- перевод по нужному delivery status.
|
||||||
|
|
||||||
|
Убрать:
|
||||||
|
- блок “Логика каналов”;
|
||||||
|
- широкую bot-control механику;
|
||||||
|
- лишние delivery-set детали производства.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-run logistics-focused tests**
|
||||||
|
|
||||||
|
Run: `npm test -- src/services/orderService.test.js`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 6: Упростить экран водителя
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/components/driver/DriverDeliveryPlanner.jsx`
|
||||||
|
- Modify: `src/components/driver/DriverDeliveryDetail.jsx`
|
||||||
|
- Modify: `src/pages/DashboardPage.jsx`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Remove planner-heavy behaviors**
|
||||||
|
|
||||||
|
Убрать:
|
||||||
|
- `kanban` view;
|
||||||
|
- reorder drag-and-drop;
|
||||||
|
- лишние фильтры, которые не нужны для показа.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Keep delivery essentials only**
|
||||||
|
|
||||||
|
Оставить:
|
||||||
|
- адрес;
|
||||||
|
- клиент;
|
||||||
|
- слот доставки;
|
||||||
|
- состав заказа;
|
||||||
|
- базовые статусы водителя.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-run driver-facing tests**
|
||||||
|
|
||||||
|
Run: `npm test -- src/services/orderService.test.js`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
## Chunk 3: Client Page Completion
|
||||||
|
|
||||||
|
### Task 7: Показать клиенту состав заказа и delivery summary
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/ClientDeliveryPage.jsx`
|
||||||
|
- Modify: `src/components/client/DeliveryChoiceFlow.jsx`
|
||||||
|
- Modify: `src/components/client/DeliveryStateNotice.jsx`
|
||||||
|
- Modify: `src/services/supabase/orderRepository.js` if extra fields need mapping
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add order items to invitation view model**
|
||||||
|
|
||||||
|
Убедиться, что клиентская страница получает:
|
||||||
|
- номер заказа;
|
||||||
|
- имя клиента;
|
||||||
|
- состав заказа;
|
||||||
|
- доступные даты;
|
||||||
|
- половины дня.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Render order composition above slot choice**
|
||||||
|
|
||||||
|
Показать клиенту:
|
||||||
|
- номер заказа;
|
||||||
|
- адрес/краткое описание;
|
||||||
|
- список позиций заказа.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Keep state notices minimal and product-focused**
|
||||||
|
|
||||||
|
Оставить только сообщения для:
|
||||||
|
- активного выбора;
|
||||||
|
- уже согласованной доставки;
|
||||||
|
- передачи логисту;
|
||||||
|
- доставленного заказа.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Re-run client tests**
|
||||||
|
|
||||||
|
Run: `npm test -- src/components/client/DeliveryChoiceFlow.test.jsx src/components/client/DeliverySlotsPicker.test.jsx src/pages/ClientDeliveryPage.test.js`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
## Chunk 4: Documentation Sync
|
||||||
|
|
||||||
|
### Task 8: Синхронизировать документацию с новым scope
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/product-overview.md`
|
||||||
|
- Modify: `README.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Update product overview**
|
||||||
|
|
||||||
|
Описать только:
|
||||||
|
- менеджера;
|
||||||
|
- логиста;
|
||||||
|
- водителя;
|
||||||
|
- клиента.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove unsupported features from docs**
|
||||||
|
|
||||||
|
Убрать из описаний:
|
||||||
|
- производство;
|
||||||
|
- админку как отдельный пользовательский продукт;
|
||||||
|
- создание заказов;
|
||||||
|
- канбан/архив/историю как целевые экраны.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-read docs against current UI**
|
||||||
|
|
||||||
|
Проверить, что документация описывает ровно тот интерфейс, который остался после упрощения.
|
||||||
|
|
||||||
|
## Chunk 5: Final Verification
|
||||||
|
|
||||||
|
### Task 9: Прогнать целевую регрессию после упрощения
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Reference: `src/pages/DashboardPage.jsx`
|
||||||
|
- Reference: `src/pages/ClientDeliveryPage.jsx`
|
||||||
|
- Reference: `docs/product-overview.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run focused UI suite**
|
||||||
|
|
||||||
|
Run: `npm test -- src/pages/DashboardPage.test.jsx src/components/orders/OrdersTable.test.jsx src/components/orders/OrderDetailPanel.test.jsx src/components/orders/OrderFilters.test.jsx src/components/client/DeliveryChoiceFlow.test.jsx src/components/client/DeliverySlotsPicker.test.jsx src/pages/ClientDeliveryPage.test.js`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run service regression tests**
|
||||||
|
|
||||||
|
Run: `npm test -- src/services/orderService.test.js src/services/deliveryInvitationApi.test.js src/context/AuthContext.test.js src/components/auth/OtpLoginForm.test.jsx`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Manual acceptance check**
|
||||||
|
|
||||||
|
Проверить вручную:
|
||||||
|
- менеджер видит только список заказов и поиск;
|
||||||
|
- логист видит готовые и запланированные доставки с датой/половиной дня;
|
||||||
|
- водитель видит свои доставки и состав заказов;
|
||||||
|
- клиент видит номер заказа, состав и выбор даты/половины дня.
|
||||||
|
|
@ -6,29 +6,15 @@ import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
const ROLE_MODULES = {
|
const ROLE_MODULES = {
|
||||||
manager: [
|
manager: [
|
||||||
"Просмотр импортированных заказов",
|
"Поиск по заказу, клиенту и телефону",
|
||||||
"Поиск по клиенту, заказу и статусу",
|
"Просмотр состава и статуса заказа",
|
||||||
"Комментарии и эскалации",
|
"Работа только с доставочным реестром",
|
||||||
],
|
|
||||||
production_lead: [
|
|
||||||
"Очередь производства",
|
|
||||||
"Переключение статусов на производстве",
|
|
||||||
"Контроль готовности к отгрузке",
|
|
||||||
],
|
|
||||||
logistician: [
|
|
||||||
"Наборы доставки и слоты",
|
|
||||||
"Согласование с клиентом и назначение рейса",
|
|
||||||
"Разбор проблемных доставок и ручная работа",
|
|
||||||
],
|
],
|
||||||
|
logistician: ["Готовность заказов на сегодня", "Слоты завтра и послезавтра", "Половины дня и статус доставки"],
|
||||||
driver: [
|
driver: [
|
||||||
"Назначенные доставки и маршрут",
|
"Назначенные доставки",
|
||||||
"Загрузка, выезд и завершение рейса",
|
"Адрес, состав и слот доставки",
|
||||||
"Фиксация результата доставки",
|
"Быстрые статусы по маршруту",
|
||||||
],
|
|
||||||
admin: [
|
|
||||||
"Полный доступ к заказам и доставкам",
|
|
||||||
"Управление пользователями и ролями",
|
|
||||||
"Логи, ошибки и история действий",
|
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -44,8 +30,7 @@ export const RoleWorkspacePanel = ({ role, deliverySetBuckets }) => {
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold">{ROLE_LABELS[role]}: рабочая панель</h2>
|
<h2 className="text-lg font-semibold">{ROLE_LABELS[role]}: рабочая панель</h2>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
Интерфейс автоматически адаптируется под роль пользователя после входа по одноразовому
|
Интерфейс показывает только то, что нужно для повседневной работы в доставочном контуре.
|
||||||
коду.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
|
|
@ -82,4 +67,4 @@ export const RoleWorkspacePanel = ({ role, deliverySetBuckets }) => {
|
||||||
) : null}
|
) : null}
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,34 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import { getAvailableTransitionsByRole, getOrderStatusComment, getStatusTone } from "../../constants/deliveryWorkflow";
|
||||||
getAvailableTransitionsByRole,
|
|
||||||
getOrderStatusComment,
|
|
||||||
getStatusTone,
|
|
||||||
} from "../../constants/deliveryWorkflow";
|
|
||||||
import { demoUsers } from "../../data/mockAppData";
|
|
||||||
import { getDeliveryCity, getDeliveryDay, getDeliveryHalfDay } from "../../services/driverDeliveries";
|
import { getDeliveryCity, getDeliveryDay, getDeliveryHalfDay } from "../../services/driverDeliveries";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
|
const splitItem = (item) => {
|
||||||
const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен";
|
if (!item) {
|
||||||
|
return { name: "Позиция", quantity: "" };
|
||||||
|
}
|
||||||
|
|
||||||
export const DriverDeliveryDetail = ({ order, onStatusChange, users }) => {
|
if (typeof item === "string") {
|
||||||
|
const [name, quantity] = item.split("|").map((part) => part.trim());
|
||||||
|
return {
|
||||||
|
name: name || item,
|
||||||
|
quantity: quantity || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item === "object") {
|
||||||
|
return {
|
||||||
|
name: item.name || item.label || "Позиция",
|
||||||
|
quantity: typeof item.quantity === "number" ? String(item.quantity) : item.quantity || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: "Позиция", quantity: "" };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
|
||||||
if (!order) {
|
if (!order) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -22,26 +37,20 @@ export const DriverDeliveryDetail = ({ order, onStatusChange, users }) => {
|
||||||
status: order.status,
|
status: order.status,
|
||||||
role: "driver",
|
role: "driver",
|
||||||
});
|
});
|
||||||
|
const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : [];
|
||||||
const deliverySetKey = order.deliverySetKey;
|
|
||||||
const deliverySetName = order.deliverySetName;
|
|
||||||
const sourceOrderNumber = order.sourceOrderNumber;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Panel className="space-y-5 p-6">
|
<Panel className="space-y-5 p-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Доставка</p>
|
||||||
Доставка
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 text-2xl font-semibold">{order.customer.address}</h2>
|
<h2 className="mt-2 text-2xl font-semibold">{order.customer.address}</h2>
|
||||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
{order.orderNumber} · {order.customer.name}
|
{order.orderNumber} · {order.customer.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge tone="neutral">Точка {order.driverRouteOrder || "\u2014"}</Badge>
|
|
||||||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -69,41 +78,26 @@ export const DriverDeliveryDetail = ({ order, onStatusChange, users }) => {
|
||||||
{getDeliveryDay(order)} · {getDeliveryHalfDay(order)}
|
{getDeliveryDay(order)} · {getDeliveryHalfDay(order)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Логист</p>
|
|
||||||
<p className="mt-1 font-medium">{resolveUserName(users, order.logisticianIds?.[0])}</p>
|
|
||||||
</div>
|
|
||||||
{sourceOrderNumber ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">1С номер</p>
|
|
||||||
<p className="mt-1 font-medium">{sourceOrderNumber}</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{deliverySetKey ? (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Набор доставки</p>
|
|
||||||
<p className="mt-1 font-medium">{deliverySetName || deliverySetKey}</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel className="space-y-4 p-6">
|
<Panel className="space-y-4 p-6">
|
||||||
<h3 className="text-lg font-semibold">Что везти</h3>
|
<h3 className="text-lg font-semibold">Что везти</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{(order.items || []).map((item) => (
|
{orderItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item}
|
key={`${item.name}-${item.quantity || "item"}`}
|
||||||
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
|
className="flex items-center justify-between gap-3 rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
|
||||||
>
|
>
|
||||||
{item}
|
<span>{item.name}</span>
|
||||||
|
{item.quantity ? <Badge tone="neutral">{item.quantity}</Badge> : null}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel className="space-y-4 p-6">
|
<Panel className="space-y-4 p-6">
|
||||||
<h3 className="text-lg font-semibold">Комментарии для водителя</h3>
|
<h3 className="text-lg font-semibold">Комментарии для доставки</h3>
|
||||||
<div className="space-y-3 text-sm text-[var(--color-text)]">
|
<div className="space-y-3 text-sm text-[var(--color-text)]">
|
||||||
<div className="rounded-[20px] bg-[var(--color-surface)] p-4">
|
<div className="rounded-[20px] bg-[var(--color-surface)] p-4">
|
||||||
{order.orderNotes?.[0]?.text || "Дополнительных комментариев нет."}
|
{order.orderNotes?.[0]?.text || "Дополнительных комментариев нет."}
|
||||||
|
|
@ -116,20 +110,22 @@ export const DriverDeliveryDetail = ({ order, onStatusChange, users }) => {
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel className="space-y-4 p-6">
|
{availableTransitions.length ? (
|
||||||
<h3 className="text-lg font-semibold">Быстрые действия</h3>
|
<Panel className="space-y-4 p-6">
|
||||||
<div className="flex flex-wrap gap-2">
|
<h3 className="text-lg font-semibold">Быстрые действия</h3>
|
||||||
{availableTransitions.map((status) => (
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
{availableTransitions.map((status) => (
|
||||||
key={status}
|
<Button
|
||||||
variant={status === "\u041F\u0440\u043E\u0431\u043B\u0435\u043C\u0430 \u0434\u043E\u0441\u0442\u0430\u0432\u043A\u0438" ? "ghost" : "secondary"}
|
key={status}
|
||||||
onClick={() => onStatusChange(status)}
|
variant={status === "Проблема доставки" ? "ghost" : "secondary"}
|
||||||
>
|
onClick={() => onStatusChange?.(status)}
|
||||||
{status}
|
>
|
||||||
</Button>
|
{status}
|
||||||
))}
|
</Button>
|
||||||
</div>
|
))}
|
||||||
</Panel>
|
</div>
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DriverDeliveryDetail } from "./DriverDeliveryDetail";
|
||||||
|
|
||||||
|
describe("DriverDeliveryDetail", () => {
|
||||||
|
it("shows the delivery essentials and order contents without source-order noise", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<DriverDeliveryDetail
|
||||||
|
order={{
|
||||||
|
orderNumber: "CD-240031",
|
||||||
|
status: "Назначен водитель",
|
||||||
|
customer: {
|
||||||
|
name: "Мария Волкова",
|
||||||
|
phone: "+7 978 000-12-31",
|
||||||
|
address: "Симферополь, ул. Ленина, 10",
|
||||||
|
},
|
||||||
|
items: ["Кухонный гарнитур | 1 комплект", "Фурнитура Blum | 12 шт"],
|
||||||
|
orderNotes: [{ text: "Подъезд узкий" }],
|
||||||
|
comments: ["Позвонить за час"],
|
||||||
|
scheduledDelivery: "2026-04-15T12:00:00Z",
|
||||||
|
deliverySlots: [{ id: "slot-1", date: "2026-04-15", time: "До обеда" }],
|
||||||
|
}}
|
||||||
|
onStatusChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Симферополь, ул. Ленина, 10");
|
||||||
|
expect(markup).toContain("Мария Волкова");
|
||||||
|
expect(markup).toContain("Кухонный гарнитур");
|
||||||
|
expect(markup).toContain("1 комплект");
|
||||||
|
expect(markup).toContain("Фурнитура Blum");
|
||||||
|
expect(markup).toContain("12 шт");
|
||||||
|
expect(markup).not.toContain("1С номер");
|
||||||
|
expect(markup).not.toContain("Набор доставки");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,157 +1,39 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { getAvailableTransitionsByRole, getStatusTone } from "../../constants/deliveryWorkflow";
|
import { getAvailableTransitionsByRole, getStatusTone } from "../../constants/deliveryWorkflow";
|
||||||
import {
|
import { groupDriverDeliveriesByDate, getDeliveryCity, getDeliveryHalfDay } from "../../services/driverDeliveries";
|
||||||
buildDriverKanbanColumns,
|
|
||||||
filterDriverDeliveries,
|
|
||||||
getDeliveryCity,
|
|
||||||
getDeliveryHalfDay,
|
|
||||||
getDriverCities,
|
|
||||||
groupDriverDeliveriesByDate,
|
|
||||||
} from "../../services/driverDeliveries";
|
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { SegmentedTabs } from "../UI/SegmentedTabs";
|
|
||||||
import { Select } from "../UI/Select";
|
|
||||||
|
|
||||||
const formatDayLabel = (date) =>
|
export const DriverDeliveryPlanner = ({ orders, onOpenOrder, onStatusChange }) => {
|
||||||
new Date(`${date}T12:00:00`).toLocaleDateString("ru-RU", {
|
const groupedOrders = React.useMemo(() => groupDriverDeliveriesByDate(orders), [orders]);
|
||||||
day: "numeric",
|
|
||||||
month: "long",
|
|
||||||
weekday: "long",
|
|
||||||
});
|
|
||||||
|
|
||||||
export const DriverDeliveryPlanner = ({
|
|
||||||
orders,
|
|
||||||
filters,
|
|
||||||
setFilters,
|
|
||||||
onOpenOrder,
|
|
||||||
onStatusChange,
|
|
||||||
onReorder,
|
|
||||||
}) => {
|
|
||||||
const [viewTab, setViewTab] = React.useState("table");
|
|
||||||
const [dragOrderId, setDragOrderId] = React.useState(null);
|
|
||||||
const [dropOrderId, setDropOrderId] = React.useState(null);
|
|
||||||
const [dropColumnKey, setDropColumnKey] = React.useState(null);
|
|
||||||
const filteredOrders = React.useMemo(() => filterDriverDeliveries(orders, filters), [filters, orders]);
|
|
||||||
const groupedOrders = React.useMemo(() => groupDriverDeliveriesByDate(filteredOrders), [filteredOrders]);
|
|
||||||
const kanbanColumns = React.useMemo(() => buildDriverKanbanColumns(filteredOrders), [filteredOrders]);
|
|
||||||
const cityOptions = React.useMemo(() => getDriverCities(orders), [orders]);
|
|
||||||
|
|
||||||
const updateFilter = (key, value) => {
|
|
||||||
setFilters((current) => ({
|
|
||||||
...current,
|
|
||||||
[key]: value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (group, targetOrderId) => {
|
|
||||||
if (!dragOrderId || dragOrderId === targetOrderId) {
|
|
||||||
setDragOrderId(null);
|
|
||||||
setDropOrderId(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const orderedIds = group.items.map((item) => item.id);
|
|
||||||
const nextIds = orderedIds.filter((id) => id !== dragOrderId);
|
|
||||||
const dropIndex = nextIds.indexOf(targetOrderId);
|
|
||||||
|
|
||||||
nextIds.splice(dropIndex, 0, dragOrderId);
|
|
||||||
onReorder(nextIds);
|
|
||||||
setDragOrderId(null);
|
|
||||||
setDropOrderId(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKanbanDrop = (column) => {
|
|
||||||
if (!dragOrderId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
onStatusChange(dragOrderId, column.dropStatus);
|
|
||||||
setDragOrderId(null);
|
|
||||||
setDropColumnKey(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-3 p-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">План доставок</h3>
|
<h3 className="text-lg font-semibold">Мои доставки</h3>
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
Отфильтруйте доставки, затем перетаскивайте карточки внутри дня, чтобы определить
|
Список доставок с адресом, клиентом, составом заказа и базовыми действиями по статусу.
|
||||||
последовательность маршрута.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="neutral">{filteredOrders.length}</Badge>
|
<Badge tone="neutral">{orders.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
|
||||||
<input
|
|
||||||
className="w-full rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
|
||||||
type="date"
|
|
||||||
value={filters.dateFrom}
|
|
||||||
onChange={(event) => updateFilter("dateFrom", event.target.value)}
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
className="w-full rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
|
||||||
type="date"
|
|
||||||
value={filters.dateTo}
|
|
||||||
onChange={(event) => updateFilter("dateTo", event.target.value)}
|
|
||||||
/>
|
|
||||||
<Select value={filters.city} onChange={(event) => updateFilter("city", event.target.value)}>
|
|
||||||
<option value="all">Все города</option>
|
|
||||||
{cityOptions.map((city) => (
|
|
||||||
<option key={city} value={city}>
|
|
||||||
{city}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
value={filters.timeSlot}
|
|
||||||
onChange={(event) => updateFilter("timeSlot", event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="all">Любое время</option>
|
|
||||||
<option value="Первая половина дня">Первая половина дня</option>
|
|
||||||
<option value="Вторая половина дня">Вторая половина дня</option>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
value={filters.viewMode}
|
|
||||||
onChange={(event) => updateFilter("viewMode", event.target.value)}
|
|
||||||
>
|
|
||||||
<option value="active">Активные</option>
|
|
||||||
<option value="all">Все</option>
|
|
||||||
<option value="problems">Проблемные</option>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant={filters.showCompleted ? "secondary" : "ghost"}
|
|
||||||
onClick={() => updateFilter("showCompleted", !filters.showCompleted)}
|
|
||||||
>
|
|
||||||
{filters.showCompleted ? "Скрыть завершённые" : "Показать завершённые"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<SegmentedTabs
|
|
||||||
items={[
|
|
||||||
{ key: "table", label: "Таблица" },
|
|
||||||
{ key: "kanban", label: "Канбан" },
|
|
||||||
]}
|
|
||||||
activeKey={viewTab}
|
|
||||||
onChange={setViewTab}
|
|
||||||
/>
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{viewTab === "table" ? (
|
{groupedOrders.length ? (
|
||||||
groupedOrders.length ? (
|
|
||||||
groupedOrders.map((group) => (
|
groupedOrders.map((group) => (
|
||||||
<Panel key={group.date} className="space-y-4 p-5">
|
<Panel key={group.date} className="space-y-4 p-5">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-lg font-semibold capitalize">{formatDayLabel(group.date)}</h4>
|
<h4 className="text-lg font-semibold capitalize">
|
||||||
|
{new Date(`${group.date}T12:00:00`).toLocaleDateString("ru-RU", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
weekday: "long",
|
||||||
|
})}
|
||||||
|
</h4>
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
{group.items.length} {group.items.length === 1 ? "доставка" : "доставки"}
|
{group.items.length} {group.items.length === 1 ? "доставка" : "доставки"}
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -159,20 +41,7 @@ export const DriverDeliveryPlanner = ({
|
||||||
<Badge tone="neutral">{group.date}</Badge>
|
<Badge tone="neutral">{group.date}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="grid gap-3">
|
||||||
<table className="min-w-full border-collapse">
|
|
||||||
<thead className="text-left text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 font-medium">Очередь</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Адрес</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Клиент</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Окно</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Статус</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Комментарий</th>
|
|
||||||
<th className="px-4 py-3 font-medium">Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{group.items.map((order) => {
|
{group.items.map((order) => {
|
||||||
const availableTransitions = getAvailableTransitionsByRole({
|
const availableTransitions = getAvailableTransitionsByRole({
|
||||||
status: order.status,
|
status: order.status,
|
||||||
|
|
@ -180,62 +49,29 @@ export const DriverDeliveryPlanner = ({
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<button
|
||||||
key={order.id}
|
key={order.id}
|
||||||
className={[
|
type="button"
|
||||||
"cursor-pointer border-t border-[var(--color-border)] bg-[var(--color-surface-strong)] text-left transition hover:bg-[var(--color-accent-soft)]",
|
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
|
||||||
dropOrderId === order.id ? "bg-[var(--color-accent-soft)]" : "",
|
onClick={() => onOpenOrder?.(order.id)}
|
||||||
].join(" ")}
|
|
||||||
onClick={() => onOpenOrder(order.id)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
|
||||||
event.preventDefault();
|
|
||||||
onOpenOrder(order.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onDragOver={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setDropOrderId(order.id);
|
|
||||||
}}
|
|
||||||
onDragLeave={() =>
|
|
||||||
setDropOrderId((current) => (current === order.id ? null : current))
|
|
||||||
}
|
|
||||||
onDrop={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
handleDrop(group, order.id);
|
|
||||||
}}
|
|
||||||
onDragStart={() => setDragOrderId(order.id)}
|
|
||||||
onDragEnd={() => {
|
|
||||||
setDragOrderId(null);
|
|
||||||
setDropOrderId(null);
|
|
||||||
}}
|
|
||||||
draggable
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
>
|
||||||
<td className="px-4 py-4 align-top text-sm">
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<Badge tone="neutral">#{order.driverRouteOrder || "—"}</Badge>
|
<div>
|
||||||
</td>
|
<div className="font-medium">{order.customer.address}</div>
|
||||||
<td className="px-4 py-4 align-top">
|
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
<div className="font-medium">{order.customer.address}</div>
|
{order.orderNumber} · {order.customer.name}
|
||||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">{order.orderNumber}</div>
|
</div>
|
||||||
</td>
|
</div>
|
||||||
<td className="px-4 py-4 align-top text-sm">
|
|
||||||
<div>{order.customer.name}</div>
|
|
||||||
<div className="mt-1 text-[var(--color-text-muted)]">{order.customer.phone}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4 align-top text-sm text-[var(--color-text-muted)]">
|
|
||||||
<div>{getDeliveryCity(order)}</div>
|
|
||||||
<div className="mt-1">{getDeliveryHalfDay(order)}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-4 align-top">
|
|
||||||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
||||||
</td>
|
</div>
|
||||||
<td className="max-w-[300px] px-4 py-4 align-top text-sm text-[var(--color-text-muted)]">
|
|
||||||
{order.orderNotes?.[0]?.text || order.comments?.[0] || "Комментариев нет"}
|
<div className="mt-3 grid gap-2 text-sm text-[var(--color-text-muted)] md:grid-cols-3">
|
||||||
</td>
|
<div>{getDeliveryCity(order)}</div>
|
||||||
<td className="px-4 py-4 align-top">
|
<div>{getDeliveryHalfDay(order)}</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div>{order.customer.phone}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
{availableTransitions.map((status) => (
|
{availableTransitions.map((status) => (
|
||||||
<Button
|
<Button
|
||||||
key={status}
|
key={status}
|
||||||
|
|
@ -243,86 +79,24 @@ export const DriverDeliveryPlanner = ({
|
||||||
variant={status === "Проблема доставки" ? "ghost" : "secondary"}
|
variant={status === "Проблема доставки" ? "ghost" : "secondary"}
|
||||||
onClick={(event) => {
|
onClick={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
onStatusChange(order.id, status);
|
onStatusChange?.(order.id, status);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</button>
|
||||||
</tr>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</Panel>
|
</Panel>
|
||||||
))
|
))
|
||||||
) : (
|
|
||||||
<Panel className="p-6">
|
|
||||||
<h4 className="text-lg font-semibold">Доставки не найдены</h4>
|
|
||||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
|
||||||
Попробуйте изменить дату, город или режим показа.
|
|
||||||
</p>
|
|
||||||
</Panel>
|
|
||||||
)
|
|
||||||
) : kanbanColumns.some((column) => column.items.length) ? (
|
|
||||||
<div className="grid gap-3 xl:grid-cols-5">
|
|
||||||
{kanbanColumns.map((column) => (
|
|
||||||
<Panel key={column.key} className="rounded-[20px] p-3">
|
|
||||||
<div className="mb-3 flex items-center justify-between gap-3 px-1">
|
|
||||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">{column.title}</h3>
|
|
||||||
<span className="text-sm text-[var(--color-text-muted)]">{column.items.length}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
"min-h-[260px] space-y-2 rounded-[16px] border border-dashed p-2 transition",
|
|
||||||
dropColumnKey === column.key
|
|
||||||
? "border-[var(--color-accent)] bg-[var(--color-accent-soft)]"
|
|
||||||
: "border-[var(--color-border)] bg-[var(--color-surface-strong)]",
|
|
||||||
].join(" ")}
|
|
||||||
onDragOver={(event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setDropColumnKey(column.key);
|
|
||||||
}}
|
|
||||||
onDragLeave={() =>
|
|
||||||
setDropColumnKey((current) => (current === column.key ? null : current))
|
|
||||||
}
|
|
||||||
onDrop={() => handleKanbanDrop(column)}
|
|
||||||
>
|
|
||||||
{column.items.map((order) => (
|
|
||||||
<div
|
|
||||||
key={order.id}
|
|
||||||
className="cursor-grab rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 text-[var(--color-text)] shadow-sm transition hover:border-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] active:cursor-grabbing"
|
|
||||||
onClick={() => onOpenOrder(order.id)}
|
|
||||||
onDragStart={() => setDragOrderId(order.id)}
|
|
||||||
onDragEnd={() => {
|
|
||||||
setDragOrderId(null);
|
|
||||||
setDropColumnKey(null);
|
|
||||||
}}
|
|
||||||
draggable
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
>
|
|
||||||
<div className="font-medium">{order.customer.address}</div>
|
|
||||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
|
||||||
{order.orderNumber} · {order.customer.name}
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 text-xs text-[var(--color-text-muted)]">
|
|
||||||
{getDeliveryHalfDay(order)} · #{order.driverRouteOrder || "—"}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<Panel className="p-6">
|
<Panel className="p-6">
|
||||||
<h4 className="text-lg font-semibold">Доставки не найдены</h4>
|
<h4 className="text-lg font-semibold">Доставки не найдены</h4>
|
||||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
Попробуйте изменить дату, город или режим показа.
|
Сейчас у вас нет назначенных доставок.
|
||||||
</p>
|
</p>
|
||||||
</Panel>
|
</Panel>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
import React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DriverDeliveryPlanner } from "./DriverDeliveryPlanner";
|
||||||
|
|
||||||
|
const orders = [
|
||||||
|
{
|
||||||
|
id: "driver-order-1",
|
||||||
|
orderNumber: "CD-240031",
|
||||||
|
status: "К доставке",
|
||||||
|
scheduledDelivery: "2026-04-16T12:00:00Z",
|
||||||
|
customer: {
|
||||||
|
name: "Мария Волкова",
|
||||||
|
address: "Симферополь, ул. Ленина, 10",
|
||||||
|
phone: "+7 978 000-12-31",
|
||||||
|
},
|
||||||
|
orderNotes: [{ text: "Подъезд узкий" }],
|
||||||
|
comments: ["Позвонить за час"],
|
||||||
|
driverRouteOrder: 1,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("DriverDeliveryPlanner", () => {
|
||||||
|
it("renders a simple delivery list without kanban or route editing", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<DriverDeliveryPlanner
|
||||||
|
orders={orders}
|
||||||
|
onOpenOrder={() => {}}
|
||||||
|
onStatusChange={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Мои доставки");
|
||||||
|
expect(markup).toContain("CD-240031");
|
||||||
|
expect(markup).toContain("Мария Волкова");
|
||||||
|
expect(markup).toContain("Симферополь, ул. Ленина, 10");
|
||||||
|
expect(markup).not.toContain("Канбан");
|
||||||
|
expect(markup).not.toContain("Перетащите");
|
||||||
|
expect(markup).not.toContain("Календарь");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,58 +1,37 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ROLE_PERMISSIONS } from "../../constants/roles";
|
import { getDeliveryAgreementComment, getOrderStatusComment, getStatusTone } from "../../constants/deliveryWorkflow";
|
||||||
import {
|
|
||||||
getDeliveryAgreementComment,
|
|
||||||
getOrderStatusComment,
|
|
||||||
getStatusTone,
|
|
||||||
} from "../../constants/deliveryWorkflow";
|
|
||||||
import { demoUsers } from "../../data/mockAppData";
|
import { demoUsers } from "../../data/mockAppData";
|
||||||
import { getAvailableTransitionsForOrder } from "../../services/orderService";
|
|
||||||
import { formatDateTime } from "../../utils/formatters";
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
|
||||||
import { Input } from "../UI/Input";
|
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { SegmentedTabs } from "../UI/SegmentedTabs";
|
|
||||||
import { Select } from "../UI/Select";
|
|
||||||
import { ChatTimeline } from "../chat/ChatTimeline";
|
|
||||||
|
|
||||||
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
|
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
|
||||||
const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен";
|
const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен";
|
||||||
|
|
||||||
const splitItem = (item) => {
|
const splitItem = (item) => {
|
||||||
const [name, quantity] = item.split("|").map((part) => part.trim());
|
if (!item) {
|
||||||
return {
|
return { name: "Позиция", quantity: "" };
|
||||||
name: name || item,
|
}
|
||||||
quantity: quantity || "1",
|
|
||||||
};
|
if (typeof item === "string") {
|
||||||
|
const [name, quantity] = item.split("|").map((part) => part.trim());
|
||||||
|
return {
|
||||||
|
name: name || item,
|
||||||
|
quantity: quantity || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof item === "object") {
|
||||||
|
return {
|
||||||
|
name: item.name || item.label || "Позиция",
|
||||||
|
quantity: typeof item.quantity === "number" ? String(item.quantity) : item.quantity || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name: "Позиция", quantity: "" };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OrderDetailPanel = ({
|
export const OrderDetailPanel = ({ order, users }) => {
|
||||||
order,
|
|
||||||
currentUser,
|
|
||||||
onStatusChange,
|
|
||||||
onAssignDriver,
|
|
||||||
onClientMessage,
|
|
||||||
onInternalMessage,
|
|
||||||
onOrderNote,
|
|
||||||
users,
|
|
||||||
}) => {
|
|
||||||
const [nextStatus, setNextStatus] = React.useState(order?.status || "Новый");
|
|
||||||
const [selectedDriverId, setSelectedDriverId] = React.useState(order?.assignedDriverId || "");
|
|
||||||
const [clientReply, setClientReply] = React.useState("Подтверждаю доставку");
|
|
||||||
const [chatQuery, setChatQuery] = React.useState("");
|
|
||||||
const [activeTab, setActiveTab] = React.useState("overview");
|
|
||||||
const [teamReply, setTeamReply] = React.useState("Новый комментарий для команды");
|
|
||||||
const [noteReply, setNoteReply] = React.useState("Новая заметка по заказу");
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
setNextStatus(order?.status || "Новый");
|
|
||||||
setSelectedDriverId(order?.assignedDriverId || "");
|
|
||||||
setChatQuery("");
|
|
||||||
setActiveTab("overview");
|
|
||||||
setTeamReply("Новый комментарий для команды");
|
|
||||||
setNoteReply("Новая заметка по заказу");
|
|
||||||
}, [order]);
|
|
||||||
|
|
||||||
if (!order) {
|
if (!order) {
|
||||||
return (
|
return (
|
||||||
<Panel className="flex min-h-[460px] items-center justify-center">
|
<Panel className="flex min-h-[460px] items-center justify-center">
|
||||||
|
|
@ -61,33 +40,15 @@ export const OrderDetailPanel = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredMessages = order.chatMessages.filter((message) =>
|
const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : [];
|
||||||
[message.text, message.channel, message.sender]
|
const orderHistory = Array.isArray(order.history) ? order.history : [];
|
||||||
.join(" ")
|
|
||||||
.toLowerCase()
|
|
||||||
.includes(chatQuery.toLowerCase()),
|
|
||||||
);
|
|
||||||
const detailTabs = [
|
|
||||||
{ key: "overview", label: "Карточка" },
|
|
||||||
{ key: "history", label: "История" },
|
|
||||||
{ key: "chat", label: "Чат с клиентом" },
|
|
||||||
{ key: "team", label: "Команда" },
|
|
||||||
];
|
|
||||||
const availableTransitions = getAvailableTransitionsForOrder({
|
|
||||||
order,
|
|
||||||
role: currentUser.role,
|
|
||||||
});
|
|
||||||
const canAssignDriver = currentUser.role === "logistician" || currentUser.role === "admin";
|
|
||||||
const drivers = getUsers(users).filter((user) => user.role === "driver");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<Panel className="space-y-5 p-6">
|
<Panel className="space-y-5 p-6">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Карточка заказа</p>
|
||||||
Карточка заказа
|
|
||||||
</p>
|
|
||||||
<h2 className="mt-2 text-2xl font-semibold">{order.orderNumber}</h2>
|
<h2 className="mt-2 text-2xl font-semibold">{order.orderNumber}</h2>
|
||||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
{order.customer.name} · {order.customer.address}
|
{order.customer.name} · {order.customer.address}
|
||||||
|
|
@ -96,6 +57,10 @@ export const OrderDetailPanel = ({
|
||||||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
{getOrderStatusComment(order.status)}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Менеджер</p>
|
<p className="text-xs text-[var(--color-text-muted)]">Менеджер</p>
|
||||||
|
|
@ -117,299 +82,114 @@ export const OrderDetailPanel = ({
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">План доставки</p>
|
<p className="text-xs text-[var(--color-text-muted)]">План доставки</p>
|
||||||
<p className="mt-1 font-medium">{formatDateTime(order.scheduledDelivery)}</p>
|
<p className="mt-1 font-medium">{formatDateTime(order.scheduledDelivery)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Канал связи</p>
|
||||||
|
<p className="mt-1 font-medium">{order.customer.messenger}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Согласование доставки</p>
|
||||||
|
<p className="mt-1 font-medium">{order.deliveryAgreementStatus}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SegmentedTabs items={detailTabs} activeKey={activeTab} onChange={setActiveTab} />
|
|
||||||
|
|
||||||
{activeTab === "overview" ? (
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
|
||||||
<div className="order-2 space-y-4 xl:order-none">
|
|
||||||
<Panel className="p-5">
|
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<strong>Данные клиента</strong>
|
|
||||||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="mb-4 text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
{getOrderStatusComment(order.status)}
|
|
||||||
</p>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Клиент</p>
|
|
||||||
<p className="mt-1 font-medium">{order.customer.name}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Телефон</p>
|
|
||||||
<p className="mt-1 font-medium">{order.customer.phone}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Адрес</p>
|
|
||||||
<p className="mt-1 font-medium">{order.customer.address}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Канал связи</p>
|
|
||||||
<p className="mt-1 font-medium">{order.customer.messenger}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">Дата заказа</p>
|
|
||||||
<p className="mt-1 font-medium">{formatDateTime(order.createdAt)}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">План доставки</p>
|
|
||||||
<p className="mt-1 font-medium">{formatDateTime(order.scheduledDelivery)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel className="p-5">
|
|
||||||
<strong>Состав заказа</strong>
|
|
||||||
<div className="mt-4 space-y-3">
|
|
||||||
{(order.items || []).map((item) => {
|
|
||||||
const parsedItem = splitItem(item);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item}
|
|
||||||
className="flex items-center justify-between gap-3 rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
|
|
||||||
>
|
|
||||||
<span>{parsedItem.name}</span>
|
|
||||||
<Badge tone="neutral">{parsedItem.quantity}</Badge>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="order-1 space-y-4 xl:order-none">
|
|
||||||
<Panel className="space-y-4 p-5">
|
|
||||||
<div>
|
|
||||||
<strong>Управление заказом</strong>
|
|
||||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
|
||||||
Можно изменить статус заказа и оставить пояснения по нему.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-sm">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<span className="font-medium">Согласование доставки</span>
|
|
||||||
<Badge tone="neutral">{order.deliveryAgreementStatus}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 leading-6 text-[var(--color-text-muted)]">
|
|
||||||
{getDeliveryAgreementComment(order.deliveryAgreementStatus)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Select value={nextStatus} onChange={(event) => setNextStatus(event.target.value)}>
|
|
||||||
<option value={order.status}>{order.status}</option>
|
|
||||||
{availableTransitions.map((status) => (
|
|
||||||
<option key={status} value={status}>
|
|
||||||
{status}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
onClick={() => onStatusChange(nextStatus)}
|
|
||||||
disabled={nextStatus === order.status}
|
|
||||||
>
|
|
||||||
Изменить статус
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canAssignDriver ? (
|
|
||||||
<div className="space-y-3 rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">Назначение водителя</div>
|
|
||||||
<p className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
|
||||||
Выберите водителя для передачи заказа в этап доставки.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Select value={selectedDriverId} onChange={(event) => setSelectedDriverId(event.target.value)}>
|
|
||||||
<option value="">Не назначен</option>
|
|
||||||
{drivers.map((driver) => (
|
|
||||||
<option key={driver.id} value={driver.id}>
|
|
||||||
{driver.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled={(selectedDriverId || "") === (order.assignedDriverId || "")}
|
|
||||||
onClick={() => onAssignDriver?.(selectedDriverId || null)}
|
|
||||||
>
|
|
||||||
Сохранить водителя
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
Для вашей роли доступны типовые действия:
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{ROLE_PERMISSIONS[currentUser.role].map((permission) => (
|
|
||||||
<Badge key={permission}>{permission}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel className="space-y-4 p-5">
|
|
||||||
<strong>Комментарии и заметки</strong>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{(order.orderNotes || []).map((note) => (
|
|
||||||
<div
|
|
||||||
key={note.id}
|
|
||||||
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<span className="font-medium">{note.authorName}</span>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
{formatDateTime(note.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-[var(--color-text)]">{note.text}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
className="min-h-28 w-full rounded-2xl border border-[var(--color-border)] bg-transparent p-3 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
|
||||||
value={noteReply}
|
|
||||||
onChange={(event) => setNoteReply(event.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() =>
|
|
||||||
onOrderNote({
|
|
||||||
authorName: currentUser.name,
|
|
||||||
text: noteReply,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Добавить комментарий
|
|
||||||
</Button>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeTab === "history" ? (
|
|
||||||
<div className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5">
|
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<strong>История действий и переходов</strong>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Визуальная лента изменений
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{order.history.map((item) => (
|
|
||||||
<div key={item.id} className="grid gap-3 md:grid-cols-[24px_1fr]">
|
|
||||||
<div className="relative flex justify-center">
|
|
||||||
<span className="mt-2 h-3 w-3 rounded-full bg-[var(--color-accent)]" />
|
|
||||||
<span className="absolute top-6 h-full w-px bg-[var(--color-border)]" />
|
|
||||||
</div>
|
|
||||||
<div className="rounded-[22px] bg-[var(--color-surface)] p-4">
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<span className="font-medium">{item.action}</span>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
{formatDateTime(item.at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-sm">
|
|
||||||
<Badge tone="neutral">{item.oldStatus || "Начало"}</Badge>
|
|
||||||
<span className="text-[var(--color-text-muted)]">→</span>
|
|
||||||
<Badge tone="accent">{item.newStatus}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm text-[var(--color-text-muted)]">{item.userName}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeTab === "chat" ? (
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[1.25fr_0.75fr]">
|
|
||||||
<Panel className="p-5">
|
|
||||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<h3 className="text-lg font-semibold">История чата</h3>
|
|
||||||
<div className="w-full md:max-w-sm">
|
|
||||||
<Input
|
|
||||||
placeholder="Поиск по чату, каналу, типу сообщения"
|
|
||||||
value={chatQuery}
|
|
||||||
onChange={(event) => setChatQuery(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p className="mb-4 text-sm text-[var(--color-text-muted)]">
|
|
||||||
Поиск и фильтрация строятся по таблице `chat_messages`.
|
|
||||||
</p>
|
|
||||||
<ChatTimeline messages={filteredMessages} />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel className="space-y-4 p-5">
|
|
||||||
<strong>Имитация ответа клиента</strong>
|
|
||||||
<textarea
|
|
||||||
className="min-h-32 w-full rounded-2xl border border-[var(--color-border)] bg-transparent p-3 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
|
||||||
value={clientReply}
|
|
||||||
onChange={(event) => setClientReply(event.target.value)}
|
|
||||||
/>
|
|
||||||
<Button variant="secondary" onClick={() => onClientMessage(clientReply)}>
|
|
||||||
Зафиксировать сообщение клиента
|
|
||||||
</Button>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{activeTab === "team" ? (
|
|
||||||
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
|
||||||
<Panel className="p-5">
|
|
||||||
<div className="mb-4 flex items-center justify-between gap-3">
|
|
||||||
<strong>Внутренний чат сотрудников</strong>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
Менеджер, производство, логистика, водитель, админ
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{(order.internalMessages || []).map((message) => (
|
|
||||||
<div
|
|
||||||
key={message.id}
|
|
||||||
className="rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
<span className="font-medium">{message.senderName}</span>
|
|
||||||
<span className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
{formatDateTime(message.sentAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-3 text-sm leading-6 text-[var(--color-text)]">
|
|
||||||
{message.text}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<Panel className="space-y-4 p-5">
|
|
||||||
<strong>Новое сообщение команде</strong>
|
|
||||||
<textarea
|
|
||||||
className="min-h-32 w-full rounded-2xl border border-[var(--color-border)] bg-transparent p-3 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
|
||||||
value={teamReply}
|
|
||||||
onChange={(event) => setTeamReply(event.target.value)}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() =>
|
|
||||||
onInternalMessage({
|
|
||||||
senderId: currentUser.id,
|
|
||||||
senderName: currentUser.name,
|
|
||||||
text: teamReply,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Отправить в командный чат
|
|
||||||
</Button>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<strong>Данные клиента</strong>
|
||||||
|
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
{getDeliveryAgreementComment(order.deliveryAgreementStatus)}
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Клиент</p>
|
||||||
|
<p className="mt-1 font-medium">{order.customer.name}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Телефон</p>
|
||||||
|
<p className="mt-1 font-medium">{order.customer.phone}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Адрес</p>
|
||||||
|
<p className="mt-1 font-medium">{order.customer.address}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Дата доставки</p>
|
||||||
|
<p className="mt-1 font-medium">{formatDateTime(order.scheduledDelivery)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<strong>Состав заказа</strong>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{orderItems.length ? (
|
||||||
|
orderItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={`${item.name}-${item.quantity || "item"}`}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
{item.quantity ? <Badge tone="neutral">{item.quantity}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">Состав заказа не указан.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{order.orderNotes?.length ? (
|
||||||
|
<Panel className="space-y-3 p-5">
|
||||||
|
<strong>Комментарии</strong>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{order.orderNotes.map((note) => (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-sm leading-6"
|
||||||
|
>
|
||||||
|
{note.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{order.comments?.length ? (
|
||||||
|
<Panel className="space-y-3 p-5">
|
||||||
|
<strong>Дополнительные комментарии</strong>
|
||||||
|
<div className="space-y-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
{order.comments.map((comment, index) => (
|
||||||
|
<div key={`${comment}-${index}`} className="rounded-[20px] bg-[var(--color-surface)] p-4">
|
||||||
|
{comment}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{orderHistory.length ? (
|
||||||
|
<Panel className="space-y-3 p-5">
|
||||||
|
<strong>История</strong>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{orderHistory.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-sm leading-6"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<span className="font-medium">{entry.action}</span>
|
||||||
|
<span className="text-[var(--color-text-muted)]">{formatDateTime(entry.at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-[var(--color-text-muted)]">
|
||||||
|
{entry.oldStatus || "Начало"} → {entry.newStatus}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -27,21 +27,25 @@ const order = {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("OrderDetailPanel", () => {
|
describe("OrderDetailPanel", () => {
|
||||||
it("prioritizes status management in the mobile overview layout", () => {
|
it("keeps the order card read-first without workflow controls", () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<OrderDetailPanel
|
<OrderDetailPanel
|
||||||
order={order}
|
order={order}
|
||||||
currentUser={{ id: "u-manager", name: "Анна", role: "manager" }}
|
users={[
|
||||||
onStatusChange={() => {}}
|
{ id: "u-manager", name: "Анна Мельник", role: "manager" },
|
||||||
onClientMessage={() => {}}
|
{ id: "u-logistics", name: "Ольга Синицына", role: "logistician" },
|
||||||
onInternalMessage={() => {}}
|
]}
|
||||||
onOrderNote={() => {}}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("order-1");
|
expect(markup).toContain("CD-240031");
|
||||||
expect(markup).toContain("order-2");
|
expect(markup).toContain("Мария Волкова");
|
||||||
expect(markup).toContain("xl:order-none");
|
expect(markup).toContain("Кухня");
|
||||||
|
expect(markup).toContain("1 шт");
|
||||||
|
expect(markup).not.toContain("Назначение водителя");
|
||||||
|
expect(markup).not.toContain("Изменить статус");
|
||||||
|
expect(markup).not.toContain("Чат с клиентом");
|
||||||
|
expect(markup).not.toContain("Команда");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not crash when an order contains invalid date strings", () => {
|
it("does not crash when an order contains invalid date strings", () => {
|
||||||
|
|
@ -60,43 +64,18 @@ describe("OrderDetailPanel", () => {
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
currentUser={{ id: "u-manager", name: "Анна", role: "manager" }}
|
|
||||||
onStatusChange={() => {}}
|
|
||||||
onClientMessage={() => {}}
|
|
||||||
onInternalMessage={() => {}}
|
|
||||||
onOrderNote={() => {}}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Не указано");
|
expect(markup).toContain("Не указано");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows driver assignment controls for logisticians and admins only", () => {
|
it("does not expose driver assignment or status controls", () => {
|
||||||
const logisticianMarkup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(<OrderDetailPanel order={order} users={[]} />);
|
||||||
<OrderDetailPanel
|
|
||||||
order={order}
|
|
||||||
currentUser={{ id: "u-logistics", name: "Ольга", role: "logistician" }}
|
|
||||||
onStatusChange={() => {}}
|
|
||||||
onAssignDriver={() => {}}
|
|
||||||
onClientMessage={() => {}}
|
|
||||||
onInternalMessage={() => {}}
|
|
||||||
onOrderNote={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
const managerMarkup = renderToStaticMarkup(
|
|
||||||
<OrderDetailPanel
|
|
||||||
order={order}
|
|
||||||
currentUser={{ id: "u-manager", name: "Анна", role: "manager" }}
|
|
||||||
onStatusChange={() => {}}
|
|
||||||
onAssignDriver={() => {}}
|
|
||||||
onClientMessage={() => {}}
|
|
||||||
onInternalMessage={() => {}}
|
|
||||||
onOrderNote={() => {}}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(logisticianMarkup).toContain("Назначение водителя");
|
expect(markup).not.toContain("Назначение водителя");
|
||||||
expect(logisticianMarkup).toContain("Артём Громов");
|
expect(markup).not.toContain("Изменить статус");
|
||||||
expect(managerMarkup).not.toContain("Назначение водителя");
|
expect(markup).not.toContain("Чат с клиентом");
|
||||||
|
expect(markup).not.toContain("Команда");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,18 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { WORKFLOW_STAGES } from "../../constants/deliveryWorkflow";
|
|
||||||
import { ORDER_STATUSES } from "../../constants/orderStatuses";
|
import { ORDER_STATUSES } from "../../constants/orderStatuses";
|
||||||
import { ROLE_LABELS } from "../../constants/roles";
|
|
||||||
import { demoUsers } from "../../data/mockAppData";
|
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { Input } from "../UI/Input";
|
import { Input } from "../UI/Input";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { Select } from "../UI/Select";
|
import { Select } from "../UI/Select";
|
||||||
|
|
||||||
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
|
|
||||||
const messengers = ["Телеграм", "ВКонтакте", "Макс", "СМС", "Эл. почта"];
|
const messengers = ["Телеграм", "ВКонтакте", "Макс", "СМС", "Эл. почта"];
|
||||||
const responsibilityRoles = Object.entries(ROLE_LABELS).filter(([role]) => role !== "admin");
|
|
||||||
const agingOptions = [
|
|
||||||
{ key: "warning", label: "Требуют внимания" },
|
|
||||||
{ key: "critical", label: "Просрочены" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export const OrderFilters = ({ filters, setFilters, users }) => {
|
export const OrderFilters = ({ filters, setFilters }) => {
|
||||||
const [isMobileFiltersOpen, setIsMobileFiltersOpen] = React.useState(false);
|
const [isMobileFiltersOpen, setIsMobileFiltersOpen] = React.useState(false);
|
||||||
const liveUsers = getUsers(users);
|
|
||||||
const logisticians = liveUsers.filter((user) => user.role === "logistician");
|
|
||||||
const managers = liveUsers.filter((user) => user.role === "manager" || user.role === "admin");
|
|
||||||
|
|
||||||
const activeChips = [
|
const activeChips = [
|
||||||
filters.status !== "all" ? { key: "status", label: filters.status } : null,
|
filters.status !== "all" ? { key: "status", label: filters.status } : null,
|
||||||
filters.stage !== "all"
|
|
||||||
? { key: "stage", label: WORKFLOW_STAGES.find((stage) => stage.key === filters.stage)?.label }
|
|
||||||
: null,
|
|
||||||
filters.ownerRole !== "all" ? { key: "ownerRole", label: ROLE_LABELS[filters.ownerRole] } : null,
|
|
||||||
filters.agingState !== "all"
|
|
||||||
? { key: "agingState", label: agingOptions.find((option) => option.key === filters.agingState)?.label }
|
|
||||||
: null,
|
|
||||||
filters.managerId !== "all"
|
|
||||||
? { key: "managerId", label: managers.find((manager) => manager.id === filters.managerId)?.name }
|
|
||||||
: null,
|
|
||||||
filters.logisticianId !== "all"
|
|
||||||
? {
|
|
||||||
key: "logisticianId",
|
|
||||||
label: logisticians.find((logistician) => logistician.id === filters.logisticianId)?.name,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
filters.messenger !== "all" ? { key: "messenger", label: filters.messenger } : null,
|
filters.messenger !== "all" ? { key: "messenger", label: filters.messenger } : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
|
|
@ -61,7 +33,7 @@ export const OrderFilters = ({ filters, setFilters, users }) => {
|
||||||
|
|
||||||
const renderAdvancedFilters = ({ className = "", showLabels = false } = {}) => (
|
const renderAdvancedFilters = ({ className = "", showLabels = false } = {}) => (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-7">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{renderFilterField(
|
{renderFilterField(
|
||||||
"Статус",
|
"Статус",
|
||||||
<Select value={filters.status} onChange={(event) => updateFilter("status", event.target.value)}>
|
<Select value={filters.status} onChange={(event) => updateFilter("status", event.target.value)}>
|
||||||
|
|
@ -75,71 +47,6 @@ export const OrderFilters = ({ filters, setFilters, users }) => {
|
||||||
showLabels,
|
showLabels,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{renderFilterField(
|
|
||||||
"Этап",
|
|
||||||
<Select value={filters.stage} onChange={(event) => updateFilter("stage", event.target.value)}>
|
|
||||||
<option value="all">Все этапы</option>
|
|
||||||
{WORKFLOW_STAGES.map((stage) => (
|
|
||||||
<option key={stage.key} value={stage.key}>
|
|
||||||
{stage.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>,
|
|
||||||
showLabels,
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderFilterField(
|
|
||||||
"Ответственный отдел",
|
|
||||||
<Select value={filters.ownerRole} onChange={(event) => updateFilter("ownerRole", event.target.value)}>
|
|
||||||
<option value="all">Все зоны ответственности</option>
|
|
||||||
{responsibilityRoles.map(([role, label]) => (
|
|
||||||
<option key={role} value={role}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>,
|
|
||||||
showLabels,
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderFilterField(
|
|
||||||
"SLA",
|
|
||||||
<Select value={filters.agingState} onChange={(event) => updateFilter("agingState", event.target.value)}>
|
|
||||||
<option value="all">Без фильтра по SLA</option>
|
|
||||||
{agingOptions.map((option) => (
|
|
||||||
<option key={option.key} value={option.key}>
|
|
||||||
{option.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>,
|
|
||||||
showLabels,
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderFilterField(
|
|
||||||
"Менеджер",
|
|
||||||
<Select value={filters.managerId} onChange={(event) => updateFilter("managerId", event.target.value)}>
|
|
||||||
<option value="all">Все менеджеры</option>
|
|
||||||
{managers.map((manager) => (
|
|
||||||
<option key={manager.id} value={manager.id}>
|
|
||||||
{manager.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>,
|
|
||||||
showLabels,
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderFilterField(
|
|
||||||
"Логист",
|
|
||||||
<Select value={filters.logisticianId} onChange={(event) => updateFilter("logisticianId", event.target.value)}>
|
|
||||||
<option value="all">Все логисты</option>
|
|
||||||
{logisticians.map((logistician) => (
|
|
||||||
<option key={logistician.id} value={logistician.id}>
|
|
||||||
{logistician.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</Select>,
|
|
||||||
showLabels,
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderFilterField(
|
{renderFilterField(
|
||||||
"Канал",
|
"Канал",
|
||||||
<Select value={filters.messenger} onChange={(event) => updateFilter("messenger", event.target.value)}>
|
<Select value={filters.messenger} onChange={(event) => updateFilter("messenger", event.target.value)}>
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { describe, expect, it } from "vitest";
|
||||||
import { OrderFilters } from "./OrderFilters";
|
import { OrderFilters } from "./OrderFilters";
|
||||||
|
|
||||||
describe("OrderFilters", () => {
|
describe("OrderFilters", () => {
|
||||||
it("renders search, stage, responsibility and aging filters", () => {
|
it("renders only the manager delivery filters", () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<OrderFilters
|
<OrderFilters
|
||||||
filters={{
|
filters={{
|
||||||
|
|
@ -22,13 +22,13 @@ describe("OrderFilters", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Поиск по заявке, клиенту, телефону");
|
expect(markup).toContain("Поиск по заявке, клиенту, телефону");
|
||||||
expect(markup).toContain("Все этапы");
|
|
||||||
expect(markup).toContain("Все зоны ответственности");
|
|
||||||
expect(markup).toContain("Без фильтра по SLA");
|
|
||||||
expect(markup).toContain("Фильтры");
|
|
||||||
expect(markup).toContain("Активные фильтры");
|
expect(markup).toContain("Активные фильтры");
|
||||||
expect(markup).toContain("Статус");
|
expect(markup).toContain("Статус");
|
||||||
expect(markup).toContain("Этап");
|
expect(markup).toContain("Канал");
|
||||||
expect(markup).toContain("Ответственный отдел");
|
expect(markup).not.toContain("Все этапы");
|
||||||
|
expect(markup).not.toContain("Все зоны ответственности");
|
||||||
|
expect(markup).not.toContain("Без фильтра по SLA");
|
||||||
|
expect(markup).not.toContain("Менеджер");
|
||||||
|
expect(markup).not.toContain("Логист");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,201 @@
|
||||||
|
import React from "react";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { DashboardPage } from "./DashboardPage";
|
||||||
|
|
||||||
|
const { useAuthMock, useOrdersMock } = vi.hoisted(() => ({
|
||||||
|
useAuthMock: vi.fn(),
|
||||||
|
useOrdersMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../context/AuthContext", () => ({
|
||||||
|
useAuth: useAuthMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../hooks/useOrders", () => ({
|
||||||
|
useOrders: useOrdersMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../layouts/AppShell", () => ({
|
||||||
|
AppShell: ({ children }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseOrder = {
|
||||||
|
id: "order-1",
|
||||||
|
orderNumber: "CD-240031",
|
||||||
|
status: "Ожидает согласования доставки",
|
||||||
|
updatedAt: "2026-04-15T09:00:00Z",
|
||||||
|
createdAt: "2026-04-14T09:00:00Z",
|
||||||
|
scheduledDelivery: "2026-04-16T12:00:00Z",
|
||||||
|
customer: {
|
||||||
|
name: "Мария Волкова",
|
||||||
|
phone: "+7 978 000-12-31",
|
||||||
|
address: "Симферополь, ул. Ленина, 10",
|
||||||
|
messenger: "Телеграм",
|
||||||
|
items: ["Кухонный гарнитур | 1 комплект"],
|
||||||
|
},
|
||||||
|
items: ["Кухонный гарнитур | 1 комплект"],
|
||||||
|
comments: ["Нужен звонок за час"],
|
||||||
|
orderNotes: [{ id: "note-1", text: "Подъезд узкий" }],
|
||||||
|
history: [],
|
||||||
|
chatMessages: [],
|
||||||
|
deliverySlots: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockOrdersState = {
|
||||||
|
orders: [baseOrder],
|
||||||
|
allOrders: [baseOrder],
|
||||||
|
selectedOrder: baseOrder,
|
||||||
|
selectedOrderId: baseOrder.id,
|
||||||
|
setSelectedOrderId: vi.fn(),
|
||||||
|
filters: {
|
||||||
|
query: "",
|
||||||
|
status: "all",
|
||||||
|
stage: "all",
|
||||||
|
ownerRole: "all",
|
||||||
|
agingState: "all",
|
||||||
|
managerId: "all",
|
||||||
|
logisticianId: "all",
|
||||||
|
messenger: "all",
|
||||||
|
},
|
||||||
|
setFilters: vi.fn(),
|
||||||
|
notifications: [],
|
||||||
|
pushNotification: vi.fn(),
|
||||||
|
updateStatus: vi.fn(),
|
||||||
|
addChatMessage: vi.fn(),
|
||||||
|
addInternalMessage: vi.fn(),
|
||||||
|
addOrderNote: vi.fn(),
|
||||||
|
assignDriver: vi.fn(),
|
||||||
|
reassignDelivery: vi.fn(),
|
||||||
|
autoAssignLogisticians: vi.fn(),
|
||||||
|
saveDriverRouteOrder: vi.fn(),
|
||||||
|
metrics: {
|
||||||
|
total: 1,
|
||||||
|
readyToShip: 1,
|
||||||
|
awaitingDeliveryCoordination: 1,
|
||||||
|
exceptions: 0,
|
||||||
|
inLogistics: 1,
|
||||||
|
},
|
||||||
|
agingAlerts: [],
|
||||||
|
agingSummary: { warning: 0, critical: 0 },
|
||||||
|
deliverySetBuckets: {
|
||||||
|
ready_to_launch: [baseOrder],
|
||||||
|
waiting: [],
|
||||||
|
problem: [],
|
||||||
|
},
|
||||||
|
users: [
|
||||||
|
{ id: "u-manager", name: "Анна", role: "manager" },
|
||||||
|
{ id: "u-logistics", name: "Ольга", role: "logistician" },
|
||||||
|
{ id: "u-driver", name: "Иван", role: "driver" },
|
||||||
|
],
|
||||||
|
isSupabaseBacked: true,
|
||||||
|
isLoading: false,
|
||||||
|
loadError: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("DashboardPage", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date("2026-04-15T09:00:00Z"));
|
||||||
|
useOrdersMock.mockReturnValue(mockOrdersState);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the manager dashboard on the delivery registry only", () => {
|
||||||
|
useAuthMock.mockReturnValue({
|
||||||
|
user: { id: "u-manager", name: "Анна", role: "manager" },
|
||||||
|
signOut: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<MemoryRouter>
|
||||||
|
<DashboardPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Реестр заказов");
|
||||||
|
expect(markup).not.toContain("Производство");
|
||||||
|
expect(markup).not.toContain("Администрирование");
|
||||||
|
expect(markup).not.toContain("Справочники");
|
||||||
|
expect(markup).not.toContain("Календарь");
|
||||||
|
expect(markup).not.toContain("Канбан");
|
||||||
|
expect(markup).not.toContain("История");
|
||||||
|
expect(markup).not.toContain("Архив");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the logistician dashboard free of bot control and extra workspace", () => {
|
||||||
|
useAuthMock.mockReturnValue({
|
||||||
|
user: { id: "u-logistics", name: "Ольга", role: "logistician" },
|
||||||
|
signOut: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<MemoryRouter>
|
||||||
|
<DashboardPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Логист: рабочая панель");
|
||||||
|
expect(markup).toContain("Сегодня");
|
||||||
|
expect(markup).not.toContain("Управление ботами");
|
||||||
|
expect(markup).not.toContain("Логика каналов");
|
||||||
|
expect(markup).not.toContain("Производство");
|
||||||
|
expect(markup).not.toContain("Администрирование");
|
||||||
|
expect(markup).not.toContain("Справочники");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the driver dashboard on the deliveries list only", () => {
|
||||||
|
useOrdersMock.mockReturnValue({
|
||||||
|
...mockOrdersState,
|
||||||
|
orders: [
|
||||||
|
{
|
||||||
|
...baseOrder,
|
||||||
|
id: "driver-order-1",
|
||||||
|
status: "Назначен водитель",
|
||||||
|
scheduledDelivery: "2026-04-15T12:00:00Z",
|
||||||
|
deliverySlots: [{ id: "slot-1", date: "2026-04-15", time: "До обеда" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allOrders: [
|
||||||
|
{
|
||||||
|
...baseOrder,
|
||||||
|
id: "driver-order-1",
|
||||||
|
status: "Назначен водитель",
|
||||||
|
scheduledDelivery: "2026-04-15T12:00:00Z",
|
||||||
|
deliverySlots: [{ id: "slot-1", date: "2026-04-15", time: "До обеда" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectedOrder: {
|
||||||
|
...baseOrder,
|
||||||
|
id: "driver-order-1",
|
||||||
|
status: "Назначен водитель",
|
||||||
|
scheduledDelivery: "2026-04-15T12:00:00Z",
|
||||||
|
deliverySlots: [{ id: "slot-1", date: "2026-04-15", time: "До обеда" }],
|
||||||
|
},
|
||||||
|
selectedOrderId: "driver-order-1",
|
||||||
|
});
|
||||||
|
useAuthMock.mockReturnValue({
|
||||||
|
user: { id: "u-driver", name: "Иван", role: "driver" },
|
||||||
|
signOut: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<MemoryRouter>
|
||||||
|
<DashboardPage />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Мои доставки");
|
||||||
|
expect(markup).toContain("CD-240031");
|
||||||
|
expect(markup).toContain("Мария Волкова");
|
||||||
|
expect(markup).not.toContain("Канбан");
|
||||||
|
expect(markup).not.toContain("Календарь");
|
||||||
|
expect(markup).not.toContain("История");
|
||||||
|
expect(markup).not.toContain("Архив");
|
||||||
|
expect(markup).not.toContain("Производство");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -57,6 +57,31 @@ begin
|
||||||
end if;
|
end if;
|
||||||
end $$;
|
end $$;
|
||||||
|
|
||||||
|
alter table public.orders add column if not exists source_order_number text;
|
||||||
|
alter table public.orders add column if not exists source_order_date date;
|
||||||
|
alter table public.orders add column if not exists source_customer_name text;
|
||||||
|
alter table public.orders add column if not exists source_customer_phone text;
|
||||||
|
alter table public.orders add column if not exists source_customer_email text;
|
||||||
|
alter table public.orders add column if not exists source_customer_city text;
|
||||||
|
alter table public.orders add column if not exists source_total_sum numeric;
|
||||||
|
alter table public.orders add column if not exists source_paid_at timestamptz;
|
||||||
|
alter table public.orders add column if not exists source_gateway text;
|
||||||
|
alter table public.orders add column if not exists source_associated_bills_text text;
|
||||||
|
alter table public.orders add column if not exists source_production_at timestamptz;
|
||||||
|
alter table public.orders add column if not exists source_saw_at timestamptz;
|
||||||
|
alter table public.orders add column if not exists source_glue_at timestamptz;
|
||||||
|
alter table public.orders add column if not exists source_h_glue_at timestamptz;
|
||||||
|
alter table public.orders add column if not exists source_curve_at timestamptz;
|
||||||
|
alter table public.orders add column if not exists source_accept_at timestamptz;
|
||||||
|
alter table public.orders add column if not exists source_ship_at timestamptz;
|
||||||
|
alter table public.orders add column if not exists source_payload jsonb;
|
||||||
|
alter table public.orders add column if not exists delivery_set_key text;
|
||||||
|
alter table public.orders add column if not exists delivery_set_name text;
|
||||||
|
alter table public.orders add column if not exists delivery_set_status text;
|
||||||
|
alter table public.orders add column if not exists delivery_set_ready_at timestamptz;
|
||||||
|
alter table public.orders add column if not exists delivery_ready_reason text;
|
||||||
|
alter table public.orders add column if not exists source_sms_legacy_at timestamptz;
|
||||||
|
|
||||||
delete from public.order_history where order_id in (
|
delete from public.order_history where order_id in (
|
||||||
select id from public.orders where order_number in ('CD-240031', 'CD-240032', 'CD-240033', 'CD-240034', 'CD-240035', 'CD-240036', 'CD-240037')
|
select id from public.orders where order_number in ('CD-240031', 'CD-240032', 'CD-240033', 'CD-240034', 'CD-240035', 'CD-240036', 'CD-240037')
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue