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
|
||||
|
||||
React-приложение для управления заказами, доставкой, ролями сотрудников и публичным согласованием доставки с клиентом.
|
||||
React-приложение для управления доставкой заказов. В текущем контуре есть три внутренние роли и публичная страница клиента: менеджер, логист, водитель и клиент.
|
||||
|
||||
## Запуск
|
||||
|
||||
|
|
@ -16,11 +16,12 @@ npm run dev
|
|||
## Что уже есть
|
||||
|
||||
- OTP-вход по email через Supabase Auth.
|
||||
- Role-based dashboard для менеджера, логиста, водителя и администратора.
|
||||
- Карточка заказа с историей, чатом и слотами доставки.
|
||||
- Публичная страница `/delivery/:token` для выбора даты и половины дня доставки.
|
||||
- Служебный вход `roles@local` для демонстрации ролей менеджера, логиста и водителя.
|
||||
- Role-based dashboard для менеджера, логиста и водителя.
|
||||
- Карточка заказа с составом, комментариями и историей.
|
||||
- Публичная страница `/delivery/:token` для выбора даты, половины дня и просмотра состава заказа.
|
||||
- Supabase SQL-схема, таблицы приглашений и Edge Functions для invitation flow.
|
||||
- Документация по архитектуре, сценариям и интеграциям.
|
||||
- Документация по продукту, архитектуре и сценариям.
|
||||
|
||||
## Структура
|
||||
|
||||
|
|
|
|||
|
|
@ -1,76 +1,64 @@
|
|||
# SuperSam: Обзор системы
|
||||
|
||||
`SuperSam` — это система управления доставкой заказов, которая объединяет внутренние рабочие кабинеты сотрудников и публичную страницу для клиента. Приложение помогает пройти путь от готовности заказа к отгрузке до согласования доставки, назначения исполнителей, фиксации результата и контроля исключений.
|
||||
`SuperSam` — это система управления доставкой заказов. В текущем scope она состоит из трёх внутренних ролей и одной публичной страницы для клиента: менеджер, логист, водитель и клиент.
|
||||
|
||||
## Задачи приложения
|
||||
|
||||
- собрать в одном месте информацию по заказам, доставке, истории действий и коммуникациям;
|
||||
- разделить рабочие зоны по ролям, чтобы каждый сотрудник видел только свой контур задач;
|
||||
- дать логисту инструмент для запуска и контроля согласования доставки;
|
||||
- дать клиенту простую ссылку, по которой он может выбрать дату и половину дня доставки;
|
||||
- сохранить в Supabase историю изменений, статусы и интеграционные события.
|
||||
- показать менеджеру единый реестр доставочных заказов с поиском и карточкой заказа;
|
||||
- показать логисту список доставок на сегодня и ближайшие дни с половинами дня;
|
||||
- показать водителю свои доставки, адрес, состав заказа и базовые статусы;
|
||||
- дать клиенту публичную ссылку, по которой он выбирает дату и половину дня доставки;
|
||||
- хранить состояние заказов, приглашений и истории изменений в Supabase.
|
||||
|
||||
## Роли
|
||||
|
||||
### Менеджер
|
||||
|
||||
Менеджер работает с заказами на ранних этапах:
|
||||
- видит список заказов и карточки клиентов;
|
||||
- следит за составом заказа и комментариями;
|
||||
- передаёт заказ дальше по процессу после подтверждения.
|
||||
- видит список заказов доставки;
|
||||
- ищет по номеру заказа, клиенту и телефону;
|
||||
- открывает карточку заказа и смотрит состав, комментарии и историю;
|
||||
- не работает с созданием заказов и внутренними служебными экранами.
|
||||
|
||||
### Логист
|
||||
|
||||
Логист отвечает за доставку:
|
||||
- видит готовые к запуску и проблемные заказы;
|
||||
- контролирует статусы согласования доставки;
|
||||
- назначает и корректирует слоты;
|
||||
- переводит заказ в ручную обработку, если клиент не ответил;
|
||||
- отслеживает историю и связанные сообщения.
|
||||
- видит заказы, готовые к доставке;
|
||||
- смотрит ближайшие даты: сегодня, завтра и послезавтра;
|
||||
- смотрит половину дня и текущий статус доставки;
|
||||
- открывает карточку заказа, чтобы свериться с деталями.
|
||||
|
||||
### Водитель
|
||||
|
||||
Водитель работает только со своими доставками:
|
||||
- видит назначенные маршруты;
|
||||
- открывает карточку точки доставки;
|
||||
- фиксирует ход доставки и итоговый статус.
|
||||
|
||||
### Администратор
|
||||
|
||||
Администратор видит всю систему:
|
||||
- пользователей и роли;
|
||||
- общие списки заказов и событий;
|
||||
- состояние интеграций и служебные данные.
|
||||
- видит только свои доставки;
|
||||
- открывает адрес, клиента, состав заказа и комментарии;
|
||||
- меняет базовый статус доставки по маршруту.
|
||||
|
||||
### Клиент
|
||||
|
||||
Клиент не входит во внутренний кабинет. Он получает публичную ссылку вида `/delivery/:token` и по ней:
|
||||
- видит номер заказа;
|
||||
- выбирает удобную дату;
|
||||
- выбирает половину дня: `До обеда` или `После обеда`;
|
||||
- подтверждает выбор.
|
||||
- получает публичную ссылку вида `/delivery/:token`;
|
||||
- видит номер заказа и состав заказа;
|
||||
- выбирает дату и половину дня: `До обеда` или `После обеда`;
|
||||
- подтверждает выбор без входа во внутренний кабинет.
|
||||
|
||||
## Основные сценарии
|
||||
|
||||
### Внутренний сценарий
|
||||
|
||||
1. Заказ попадает в систему.
|
||||
2. Менеджер и внутренние сотрудники ведут заказ по этапам.
|
||||
3. Когда заказ готов к доставке, логист запускает приглашение клиенту.
|
||||
4. Клиент выбирает слот по публичной ссылке.
|
||||
5. Система переводит заказ в `Доставка согласована`.
|
||||
6. Логист и водитель доводят доставку до результата.
|
||||
1. Заказ появляется в Supabase.
|
||||
2. Менеджер видит его в реестре и сверяет состав.
|
||||
3. Логист отслеживает готовность и ближайшее окно доставки.
|
||||
4. Водитель получает свою доставку и доводит её до результата.
|
||||
|
||||
### Сценарий клиента
|
||||
|
||||
Клиентская страница работает по token из таблицы `public.delivery_invitations`. Для рабочего показа используется заранее загруженный seed-набор данных.
|
||||
Клиентская страница работает по token из таблицы `public.delivery_invitations`.
|
||||
|
||||
После загрузки seed можно открыть ссылку:
|
||||
|
||||
`/delivery/client-flow-1001`
|
||||
|
||||
Эта ссылка должна показывать:
|
||||
Эта ссылка показывает:
|
||||
- заказ `CD-240031`;
|
||||
- состав заказа;
|
||||
- четыре варианта слота;
|
||||
- две даты;
|
||||
- две половины дня: `До обеда` и `После обеда`.
|
||||
|
|
@ -83,8 +71,6 @@
|
|||
|
||||
## Что хранится в Supabase
|
||||
|
||||
### Основные таблицы
|
||||
|
||||
- `public.users` — пользователи и роли;
|
||||
- `public.orders` — заказы и текущие статусы;
|
||||
- `public.order_history` — история изменений;
|
||||
|
|
@ -92,34 +78,24 @@
|
|||
- `public.delivery_invitations` — публичные invitation token и состояние клиентского flow;
|
||||
- `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`.
|
||||
2. Создать нужных пользователей в `auth.users`.
|
||||
3. Выполнить `supabase/seed/stage-1-demo.sql`.
|
||||
4. Убедиться, что Edge Functions развернуты:
|
||||
2. Выполнить `supabase/seed/stage-1-demo.sql`.
|
||||
3. Убедиться, что развернуты Edge Functions:
|
||||
- `get-delivery-invitation`
|
||||
- `confirm-delivery-choice`
|
||||
- `create-delivery-invitation`
|
||||
5. Открыть внутренний кабинет.
|
||||
6. Открыть клиентскую ссылку `/delivery/client-flow-1001`.
|
||||
4. Открыть внутренний кабинет и пройти вход под ролью.
|
||||
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 = {
|
||||
manager: [
|
||||
"Просмотр импортированных заказов",
|
||||
"Поиск по клиенту, заказу и статусу",
|
||||
"Комментарии и эскалации",
|
||||
],
|
||||
production_lead: [
|
||||
"Очередь производства",
|
||||
"Переключение статусов на производстве",
|
||||
"Контроль готовности к отгрузке",
|
||||
],
|
||||
logistician: [
|
||||
"Наборы доставки и слоты",
|
||||
"Согласование с клиентом и назначение рейса",
|
||||
"Разбор проблемных доставок и ручная работа",
|
||||
"Поиск по заказу, клиенту и телефону",
|
||||
"Просмотр состава и статуса заказа",
|
||||
"Работа только с доставочным реестром",
|
||||
],
|
||||
logistician: ["Готовность заказов на сегодня", "Слоты завтра и послезавтра", "Половины дня и статус доставки"],
|
||||
driver: [
|
||||
"Назначенные доставки и маршрут",
|
||||
"Загрузка, выезд и завершение рейса",
|
||||
"Фиксация результата доставки",
|
||||
],
|
||||
admin: [
|
||||
"Полный доступ к заказам и доставкам",
|
||||
"Управление пользователями и ролями",
|
||||
"Логи, ошибки и история действий",
|
||||
"Назначенные доставки",
|
||||
"Адрес, состав и слот доставки",
|
||||
"Быстрые статусы по маршруту",
|
||||
],
|
||||
};
|
||||
|
||||
|
|
@ -44,8 +30,7 @@ export const RoleWorkspacePanel = ({ role, deliverySetBuckets }) => {
|
|||
<div>
|
||||
<h2 className="text-lg font-semibold">{ROLE_LABELS[role]}: рабочая панель</h2>
|
||||
<p className="text-sm text-[var(--color-text-muted)]">
|
||||
Интерфейс автоматически адаптируется под роль пользователя после входа по одноразовому
|
||||
коду.
|
||||
Интерфейс показывает только то, что нужно для повседневной работы в доставочном контуре.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
|
|
|||
|
|
@ -1,19 +1,34 @@
|
|||
import React from "react";
|
||||
import {
|
||||
getAvailableTransitionsByRole,
|
||||
getOrderStatusComment,
|
||||
getStatusTone,
|
||||
} from "../../constants/deliveryWorkflow";
|
||||
import { demoUsers } from "../../data/mockAppData";
|
||||
import { getAvailableTransitionsByRole, getOrderStatusComment, getStatusTone } from "../../constants/deliveryWorkflow";
|
||||
import { getDeliveryCity, getDeliveryDay, getDeliveryHalfDay } from "../../services/driverDeliveries";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { Button } from "../UI/Button";
|
||||
import { Panel } from "../UI/Panel";
|
||||
|
||||
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
|
||||
const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен";
|
||||
const splitItem = (item) => {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -22,26 +37,20 @@ export const DriverDeliveryDetail = ({ order, onStatusChange, users }) => {
|
|||
status: order.status,
|
||||
role: "driver",
|
||||
});
|
||||
|
||||
const deliverySetKey = order.deliverySetKey;
|
||||
const deliverySetName = order.deliverySetName;
|
||||
const sourceOrderNumber = order.sourceOrderNumber;
|
||||
const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Panel className="space-y-5 p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||
Доставка
|
||||
</p>
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Доставка</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold">{order.customer.address}</h2>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||
{order.orderNumber} · {order.customer.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge tone="neutral">Точка {order.driverRouteOrder || "\u2014"}</Badge>
|
||||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -69,41 +78,26 @@ export const DriverDeliveryDetail = ({ order, onStatusChange, users }) => {
|
|||
{getDeliveryDay(order)} · {getDeliveryHalfDay(order)}
|
||||
</p>
|
||||
</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>
|
||||
</Panel>
|
||||
|
||||
<Panel className="space-y-4 p-6">
|
||||
<h3 className="text-lg font-semibold">Что везти</h3>
|
||||
<div className="space-y-3">
|
||||
{(order.items || []).map((item) => (
|
||||
{orderItems.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
|
||||
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"
|
||||
>
|
||||
{item}
|
||||
<span>{item.name}</span>
|
||||
{item.quantity ? <Badge tone="neutral">{item.quantity}</Badge> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<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="rounded-[20px] bg-[var(--color-surface)] p-4">
|
||||
{order.orderNotes?.[0]?.text || "Дополнительных комментариев нет."}
|
||||
|
|
@ -116,20 +110,22 @@ export const DriverDeliveryDetail = ({ order, onStatusChange, users }) => {
|
|||
</div>
|
||||
</Panel>
|
||||
|
||||
{availableTransitions.length ? (
|
||||
<Panel className="space-y-4 p-6">
|
||||
<h3 className="text-lg font-semibold">Быстрые действия</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{availableTransitions.map((status) => (
|
||||
<Button
|
||||
key={status}
|
||||
variant={status === "\u041F\u0440\u043E\u0431\u043B\u0435\u043C\u0430 \u0434\u043E\u0441\u0442\u0430\u0432\u043A\u0438" ? "ghost" : "secondary"}
|
||||
onClick={() => onStatusChange(status)}
|
||||
variant={status === "Проблема доставки" ? "ghost" : "secondary"}
|
||||
onClick={() => onStatusChange?.(status)}
|
||||
>
|
||||
{status}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
) : null}
|
||||
</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 { getAvailableTransitionsByRole, getStatusTone } from "../../constants/deliveryWorkflow";
|
||||
import {
|
||||
buildDriverKanbanColumns,
|
||||
filterDriverDeliveries,
|
||||
getDeliveryCity,
|
||||
getDeliveryHalfDay,
|
||||
getDriverCities,
|
||||
groupDriverDeliveriesByDate,
|
||||
} from "../../services/driverDeliveries";
|
||||
import { groupDriverDeliveriesByDate, getDeliveryCity, getDeliveryHalfDay } from "../../services/driverDeliveries";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { Button } from "../UI/Button";
|
||||
import { Panel } from "../UI/Panel";
|
||||
import { SegmentedTabs } from "../UI/SegmentedTabs";
|
||||
import { Select } from "../UI/Select";
|
||||
|
||||
const formatDayLabel = (date) =>
|
||||
new Date(`${date}T12:00:00`).toLocaleDateString("ru-RU", {
|
||||
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);
|
||||
};
|
||||
export const DriverDeliveryPlanner = ({ orders, onOpenOrder, onStatusChange }) => {
|
||||
const groupedOrders = React.useMemo(() => groupDriverDeliveriesByDate(orders), [orders]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Panel className="space-y-4 p-5">
|
||||
<div className="space-y-4">
|
||||
<Panel className="space-y-3 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<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>
|
||||
</div>
|
||||
<Badge tone="neutral">{filteredOrders.length}</Badge>
|
||||
<Badge tone="neutral">{orders.length}</Badge>
|
||||
</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>
|
||||
|
||||
{viewTab === "table" ? (
|
||||
groupedOrders.length ? (
|
||||
{groupedOrders.length ? (
|
||||
groupedOrders.map((group) => (
|
||||
<Panel key={group.date} className="space-y-4 p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<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)]">
|
||||
{group.items.length} {group.items.length === 1 ? "доставка" : "доставки"}
|
||||
</p>
|
||||
|
|
@ -159,20 +41,7 @@ export const DriverDeliveryPlanner = ({
|
|||
<Badge tone="neutral">{group.date}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<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>
|
||||
<div className="grid gap-3">
|
||||
{group.items.map((order) => {
|
||||
const availableTransitions = getAvailableTransitionsByRole({
|
||||
status: order.status,
|
||||
|
|
@ -180,62 +49,29 @@ export const DriverDeliveryPlanner = ({
|
|||
});
|
||||
|
||||
return (
|
||||
<tr
|
||||
<button
|
||||
key={order.id}
|
||||
className={[
|
||||
"cursor-pointer border-t border-[var(--color-border)] bg-[var(--color-surface-strong)] text-left transition hover:bg-[var(--color-accent-soft)]",
|
||||
dropOrderId === order.id ? "bg-[var(--color-accent-soft)]" : "",
|
||||
].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}
|
||||
type="button"
|
||||
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
|
||||
onClick={() => onOpenOrder?.(order.id)}
|
||||
>
|
||||
<td className="px-4 py-4 align-top text-sm">
|
||||
<Badge tone="neutral">#{order.driverRouteOrder || "—"}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-medium">{order.customer.address}</div>
|
||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">{order.orderNumber}</div>
|
||||
</td>
|
||||
<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">
|
||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{order.orderNumber} · {order.customer.name}
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
||||
</td>
|
||||
<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] || "Комментариев нет"}
|
||||
</td>
|
||||
<td className="px-4 py-4 align-top">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-2 text-sm text-[var(--color-text-muted)] md:grid-cols-3">
|
||||
<div>{getDeliveryCity(order)}</div>
|
||||
<div>{getDeliveryHalfDay(order)}</div>
|
||||
<div>{order.customer.phone}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{availableTransitions.map((status) => (
|
||||
<Button
|
||||
key={status}
|
||||
|
|
@ -243,19 +79,16 @@ export const DriverDeliveryPlanner = ({
|
|||
variant={status === "Проблема доставки" ? "ghost" : "secondary"}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onStatusChange(order.id, status);
|
||||
onStatusChange?.(order.id, status);
|
||||
}}
|
||||
>
|
||||
{status}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Panel>
|
||||
))
|
||||
|
|
@ -263,66 +96,7 @@ export const DriverDeliveryPlanner = ({
|
|||
<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">
|
||||
<h4 className="text-lg font-semibold">Доставки не найдены</h4>
|
||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||
Попробуйте изменить дату, город или режим показа.
|
||||
Сейчас у вас нет назначенных доставок.
|
||||
</p>
|
||||
</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 { 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 { getAvailableTransitionsForOrder } from "../../services/orderService";
|
||||
import { formatDateTime } from "../../utils/formatters";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { Button } from "../UI/Button";
|
||||
import { Input } from "../UI/Input";
|
||||
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 resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен";
|
||||
|
||||
const splitItem = (item) => {
|
||||
if (!item) {
|
||||
return { name: "Позиция", quantity: "" };
|
||||
}
|
||||
|
||||
if (typeof item === "string") {
|
||||
const [name, quantity] = item.split("|").map((part) => part.trim());
|
||||
return {
|
||||
name: name || item,
|
||||
quantity: quantity || "1",
|
||||
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 = ({
|
||||
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]);
|
||||
|
||||
export const OrderDetailPanel = ({ order, users }) => {
|
||||
if (!order) {
|
||||
return (
|
||||
<Panel className="flex min-h-[460px] items-center justify-center">
|
||||
|
|
@ -61,33 +40,15 @@ export const OrderDetailPanel = ({
|
|||
);
|
||||
}
|
||||
|
||||
const filteredMessages = order.chatMessages.filter((message) =>
|
||||
[message.text, message.channel, message.sender]
|
||||
.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");
|
||||
const orderItems = Array.isArray(order.items) ? order.items.map(splitItem) : [];
|
||||
const orderHistory = Array.isArray(order.history) ? order.history : [];
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<Panel className="space-y-5 p-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||
Карточка заказа
|
||||
</p>
|
||||
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">Карточка заказа</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold">{order.orderNumber}</h2>
|
||||
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||
{order.customer.name} · {order.customer.address}
|
||||
|
|
@ -96,6 +57,10 @@ export const OrderDetailPanel = ({
|
|||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
||||
</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>
|
||||
<p className="text-xs text-[var(--color-text-muted)]">Менеджер</p>
|
||||
|
|
@ -117,20 +82,24 @@ export const OrderDetailPanel = ({
|
|||
<p className="text-xs text-[var(--color-text-muted)]">План доставки</p>
|
||||
<p className="mt-1 font-medium">{formatDateTime(order.scheduledDelivery)}</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">{order.deliveryAgreementStatus}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<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">
|
||||
<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="mb-4 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||
{getOrderStatusComment(order.status)}
|
||||
<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>
|
||||
|
|
@ -146,270 +115,81 @@ export const OrderDetailPanel = ({
|
|||
<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="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">
|
||||
<Panel className="space-y-4 p-5">
|
||||
<strong>Состав заказа</strong>
|
||||
<div className="mt-4 space-y-3">
|
||||
{(order.items || []).map((item) => {
|
||||
const parsedItem = splitItem(item);
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{orderItems.length ? (
|
||||
orderItems.map((item) => (
|
||||
<div
|
||||
key={item}
|
||||
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>{parsedItem.name}</span>
|
||||
<Badge tone="neutral">{parsedItem.quantity}</Badge>
|
||||
<span>{item.name}</span>
|
||||
{item.quantity ? <Badge tone="neutral">{item.quantity}</Badge> : null}
|
||||
</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>
|
||||
))}
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-[var(--color-text-muted)]">Состав заказа не указан.</p>
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel className="space-y-4 p-5">
|
||||
<strong>Комментарии и заметки</strong>
|
||||
<div className="space-y-3">
|
||||
{(order.orderNotes || []).map((note) => (
|
||||
{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"
|
||||
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">{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>
|
||||
{note.text}
|
||||
</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>
|
||||
{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>
|
||||
</div>
|
||||
</Panel>
|
||||
) : 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) => (
|
||||
{orderHistory.length ? (
|
||||
<Panel className="space-y-3 p-5">
|
||||
<strong>История</strong>
|
||||
<div className="space-y-2">
|
||||
{orderHistory.map((entry) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className="rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface)] p-4"
|
||||
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">{message.senderName}</span>
|
||||
<span className="text-xs text-[var(--color-text-muted)]">
|
||||
{formatDateTime(message.sentAt)}
|
||||
</span>
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -27,21 +27,25 @@ const order = {
|
|||
};
|
||||
|
||||
describe("OrderDetailPanel", () => {
|
||||
it("prioritizes status management in the mobile overview layout", () => {
|
||||
it("keeps the order card read-first without workflow controls", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<OrderDetailPanel
|
||||
order={order}
|
||||
currentUser={{ id: "u-manager", name: "Анна", role: "manager" }}
|
||||
onStatusChange={() => {}}
|
||||
onClientMessage={() => {}}
|
||||
onInternalMessage={() => {}}
|
||||
onOrderNote={() => {}}
|
||||
users={[
|
||||
{ id: "u-manager", name: "Анна Мельник", role: "manager" },
|
||||
{ id: "u-logistics", name: "Ольга Синицына", role: "logistician" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("order-1");
|
||||
expect(markup).toContain("order-2");
|
||||
expect(markup).toContain("xl:order-none");
|
||||
expect(markup).toContain("CD-240031");
|
||||
expect(markup).toContain("Мария Волкова");
|
||||
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", () => {
|
||||
|
|
@ -60,43 +64,18 @@ describe("OrderDetailPanel", () => {
|
|||
},
|
||||
],
|
||||
}}
|
||||
currentUser={{ id: "u-manager", name: "Анна", role: "manager" }}
|
||||
onStatusChange={() => {}}
|
||||
onClientMessage={() => {}}
|
||||
onInternalMessage={() => {}}
|
||||
onOrderNote={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("Не указано");
|
||||
});
|
||||
|
||||
it("shows driver assignment controls for logisticians and admins only", () => {
|
||||
const logisticianMarkup = renderToStaticMarkup(
|
||||
<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={() => {}}
|
||||
/>,
|
||||
);
|
||||
it("does not expose driver assignment or status controls", () => {
|
||||
const markup = renderToStaticMarkup(<OrderDetailPanel order={order} users={[]} />);
|
||||
|
||||
expect(logisticianMarkup).toContain("Назначение водителя");
|
||||
expect(logisticianMarkup).toContain("Артём Громов");
|
||||
expect(managerMarkup).not.toContain("Назначение водителя");
|
||||
expect(markup).not.toContain("Назначение водителя");
|
||||
expect(markup).not.toContain("Изменить статус");
|
||||
expect(markup).not.toContain("Чат с клиентом");
|
||||
expect(markup).not.toContain("Команда");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,46 +1,18 @@
|
|||
import React from "react";
|
||||
import { WORKFLOW_STAGES } from "../../constants/deliveryWorkflow";
|
||||
import { ORDER_STATUSES } from "../../constants/orderStatuses";
|
||||
import { ROLE_LABELS } from "../../constants/roles";
|
||||
import { demoUsers } from "../../data/mockAppData";
|
||||
import { Badge } from "../UI/Badge";
|
||||
import { Button } from "../UI/Button";
|
||||
import { Input } from "../UI/Input";
|
||||
import { Panel } from "../UI/Panel";
|
||||
import { Select } from "../UI/Select";
|
||||
|
||||
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
|
||||
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 liveUsers = getUsers(users);
|
||||
const logisticians = liveUsers.filter((user) => user.role === "logistician");
|
||||
const managers = liveUsers.filter((user) => user.role === "manager" || user.role === "admin");
|
||||
|
||||
const activeChips = [
|
||||
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,
|
||||
].filter(Boolean);
|
||||
|
||||
|
|
@ -61,7 +33,7 @@ export const OrderFilters = ({ filters, setFilters, users }) => {
|
|||
|
||||
const renderAdvancedFilters = ({ className = "", showLabels = false } = {}) => (
|
||||
<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(
|
||||
"Статус",
|
||||
<Select value={filters.status} onChange={(event) => updateFilter("status", event.target.value)}>
|
||||
|
|
@ -75,71 +47,6 @@ export const OrderFilters = ({ filters, setFilters, users }) => {
|
|||
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(
|
||||
"Канал",
|
||||
<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";
|
||||
|
||||
describe("OrderFilters", () => {
|
||||
it("renders search, stage, responsibility and aging filters", () => {
|
||||
it("renders only the manager delivery filters", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<OrderFilters
|
||||
filters={{
|
||||
|
|
@ -22,13 +22,13 @@ describe("OrderFilters", () => {
|
|||
);
|
||||
|
||||
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).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 $$;
|
||||
|
||||
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 (
|
||||
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