feat: simplify delivery roles and docs

This commit is contained in:
Codex 2026-04-16 13:31:17 +03:00
parent c28c826601
commit a5113a2ed5
15 changed files with 1094 additions and 1790 deletions

View File

@ -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.
- Документация по архитектуре, сценариям и интеграциям. - Документация по продукту, архитектуре и сценариям.
## Структура ## Структура

View File

@ -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`.
## Что показывать на встрече ## Что показывать на встрече
- вход во внутренний кабинет; - вход менеджера, логиста и водителя;
- список заказов для менеджера, логиста и водителя; - реестр заказов и карточку заказа;
- карточку заказа и статусы; - список доставок по датам для логиста;
- клиентскую ссылку с выбором даты и половины дня; - карточку доставки водителя;
- изменение статуса заказа после подтверждения клиентом. - клиентскую ссылку с выбором даты и половины дня.
## Полезные документы ## Полезные документы

View File

@ -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**
Проверить вручную:
- менеджер видит только список заказов и поиск;
- логист видит готовые и запланированные доставки с датой/половиной дня;
- водитель видит свои доставки и состав заказов;
- клиент видит номер заказа, состав и выбор даты/половины дня.

View File

@ -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>
); );
}; };

View File

@ -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>
); );
}; };

View File

@ -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("Набор доставки");
});
});

View File

@ -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>
)} )}

View File

@ -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("Календарь");
});
});

View File

@ -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>
); );
}; };

View File

@ -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("Команда");
}); });
}); });

View File

@ -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)}>

View File

@ -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

View File

@ -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("Производство");
});
});

View File

@ -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')
); );