Initial import
This commit is contained in:
commit
b40a4a553e
|
|
@ -0,0 +1,2 @@
|
||||||
|
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||||
|
VITE_SUPABASE_ANON_KEY=your-anon-key
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.DS_Store
|
||||||
|
.superpowers
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Construction Delivery Control
|
||||||
|
|
||||||
|
React-приложение для управления заказами, производством, логистикой и чатбот-коммуникацией через VK, Telegram и Messenger Max.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Что реализовано
|
||||||
|
|
||||||
|
- OTP-вход по email через Supabase Auth с demo-режимом без backend-конфига.
|
||||||
|
- Installable PWA-режим: приложение можно добавить на домашний экран и открыть как отдельное окно.
|
||||||
|
- Offline demo flow: после первого запуска дашборд и локальные demo-данные доступны без интернета.
|
||||||
|
- Role-based dashboard для менеджера, производства, логиста и администратора.
|
||||||
|
- Форма создания и редактирования заказов с автоназначением логиста.
|
||||||
|
- Карточка заказа с историей статусов, действий, чата и слотов доставки.
|
||||||
|
- Панели очереди производства и администраторского обзора пользователей.
|
||||||
|
- Светлая и тёмная тема, адаптивный минималистичный UI.
|
||||||
|
- Supabase SQL-схема с RLS, аудитом и расширением под нескольких логистов.
|
||||||
|
- Тестируемый сервисный слой для фильтрации, смены статусов и генерации новых заказов.
|
||||||
|
- Документация по архитектуре, ботам и пользовательским сценариям.
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
- `src/` — интерфейс и клиентская логика.
|
||||||
|
- `public/` — PWA manifest, service worker и install icons.
|
||||||
|
- `supabase/schema.sql` — структура БД, роли, индексы, RLS, триггеры.
|
||||||
|
- `supabase/functions/` — заготовки Edge Functions для webhook и отправки сообщений в боты.
|
||||||
|
- `docs/architecture.md` — архитектура фронтенда и модулей.
|
||||||
|
- `docs/chatbot-integration.md` — логика интеграции VK/Telegram/Messenger Max.
|
||||||
|
- `docs/scenarios.md` — сценарии жизненного цикла заказа.
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
# Архитектура фронтенда
|
||||||
|
|
||||||
|
## Модули
|
||||||
|
|
||||||
|
- `src/context/AuthContext.jsx` — OTP-аутентификация через Supabase Auth и загрузка профиля пользователя с ролью.
|
||||||
|
- `src/context/ThemeContext.jsx` — управление светлой и тёмной темой через `data-theme`.
|
||||||
|
- `src/hooks/usePwaStatus.js` — клиентское состояние PWA: online/offline, install prompt, standalone и offline readiness.
|
||||||
|
- `src/hooks/useOrders.js` — локальный state заказов, истории, чатов, фильтров и действий.
|
||||||
|
- `src/services/orderService.js` — чистые функции бизнес-логики заказов, покрытые тестами.
|
||||||
|
- `src/services/supabase/orderRepository.js` — адаптер реальных чтений/записей заказов и чатов в Supabase.
|
||||||
|
- `src/layouts/AppShell.jsx` — общий shell с боковой навигацией, уведомлениями и переключением темы.
|
||||||
|
- `src/components/orders/*` — фильтры, список заказов, карточка заказа, история статусов и поиск по чату.
|
||||||
|
- `src/components/orders/OrderEditorPanel.jsx` — создание и редактирование заказа менеджером или администратором.
|
||||||
|
- `src/components/dashboard/ProductionQueuePanel.jsx` — отдельный блок производственной очереди.
|
||||||
|
- `src/components/admin/UserDirectoryPanel.jsx` — панель пользователей, ролей и последних входов.
|
||||||
|
- `src/components/logistics/BotControlPanel.jsx` — сценарии отправки в чатбот и переноса слотов доставки.
|
||||||
|
- `src/components/admin/AuditPanel.jsx` — журнал ошибок, исключений и обзор последних системных событий.
|
||||||
|
|
||||||
|
## Ролевой доступ
|
||||||
|
|
||||||
|
- Менеджер видит только свои заказы и может менять статусы на этапах подтверждения.
|
||||||
|
- Начальник производства переводит заказ через очередь и производство к готовности.
|
||||||
|
- Логист работает только со своими заказами, слотами и сообщениями чатбота.
|
||||||
|
- Водитель видит только назначенные ему доставки и может переводить их через статусы `Загружен`, `В пути`, `Доставлен`.
|
||||||
|
- Администратор видит весь массив заказов и системные логи.
|
||||||
|
|
||||||
|
## Ключевые экраны
|
||||||
|
|
||||||
|
- `/login` — email + OTP flow. При отсутствии `VITE_SUPABASE_*` включается demo-режим.
|
||||||
|
- `/dashboard` — role-based control center: KPI, фильтры, список заказов, детализация, боты, аудит.
|
||||||
|
- `public/manifest.webmanifest` + `public/service-worker.js` — installable PWA-оболочка и базовое кеширование shell для demo offline.
|
||||||
|
- `src/services/orderService.test.js` — smoke-проверки фильтрации, статусов, метрик и автодистрибуции.
|
||||||
|
|
||||||
|
## Дизайн-концепт
|
||||||
|
|
||||||
|
- Минималистичная сетка с крупными панелями, прозрачными поверхностями и мягкими границами.
|
||||||
|
- Светлая тема использует холодно-зелёный акцент на светлом фоне.
|
||||||
|
- Тёмная тема построена на глубоких графитовых поверхностях с ярким мятным accent.
|
||||||
|
- Мобильная версия складывает layout в вертикальный поток; боковая панель становится верхним блоком.
|
||||||
|
|
||||||
|
## Интеграционный слой
|
||||||
|
|
||||||
|
- `src/supabaseClient.js` создаёт клиент Supabase через env-переменные.
|
||||||
|
- `src/services/safeSupabaseCall.js` стандартизирует обработку ошибок.
|
||||||
|
- Данные UI уже разложены по сущностям, совпадающим с таблицами Supabase: `orders`, `order_history`, `chat_messages`, `delivery_slots`.
|
||||||
|
- В `orders` синхронизированы поля `status`, `delivery_agreement_status`, `assigned_driver_id`, чтобы backend и demo-режим использовали одну процессную модель.
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
# Логика чатботов VK, Telegram и Messenger Max
|
||||||
|
|
||||||
|
## Общий принцип
|
||||||
|
|
||||||
|
Все каналы работают через единый слой адаптеров:
|
||||||
|
|
||||||
|
1. webhook принимает событие от канала;
|
||||||
|
2. адаптер нормализует payload в общую структуру;
|
||||||
|
3. сообщение или действие сохраняется в `chat_messages`;
|
||||||
|
4. при изменении состояния заказа создаётся запись в `order_history`;
|
||||||
|
5. UI обновляет карточку заказа и уведомления.
|
||||||
|
|
||||||
|
## Единая модель событий
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"order_id": "uuid",
|
||||||
|
"channel": "telegram | vk | messenger_max",
|
||||||
|
"direction": "inbound | outbound",
|
||||||
|
"message_type": "text | button | system",
|
||||||
|
"sender_type": "client | bot | operator",
|
||||||
|
"text": "Подтверждаю доставку",
|
||||||
|
"payload": {
|
||||||
|
"action": "confirm_delivery",
|
||||||
|
"slot_id": "uuid"
|
||||||
|
},
|
||||||
|
"external_message_id": "string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
При обновлении заказа бот теперь меняет два поля:
|
||||||
|
- `orders.status` — основной статус заказа;
|
||||||
|
- `orders.delivery_agreement_status` — состояние согласования доставки с клиентом.
|
||||||
|
|
||||||
|
## Telegram
|
||||||
|
|
||||||
|
- Отправка статуса готовности через `sendMessage`.
|
||||||
|
- Кнопки подтверждения через inline keyboard: `confirm`, `reschedule`, `cancel`.
|
||||||
|
- Callback query несёт `order_id`, `slot_id`, `action`.
|
||||||
|
- При ответе клиента обновляются `chat_messages`, `delivery_slots`, `orders.status`.
|
||||||
|
|
||||||
|
## VK
|
||||||
|
|
||||||
|
- Используется Callback API сообщества.
|
||||||
|
- Кнопки в `keyboard` отправляют payload с `order_id` и `action`.
|
||||||
|
- Система проверяет подпись webhook, нормализует payload и сохраняет событие.
|
||||||
|
|
||||||
|
## Messenger Max
|
||||||
|
|
||||||
|
- Адаптер должен принимать webhook событий сообщений и postback-действий.
|
||||||
|
- На входе событие маппится в ту же структуру, что и Telegram/VK.
|
||||||
|
- Неизвестные типы сохраняются как `system` для последующего разбора администратором.
|
||||||
|
|
||||||
|
## Автоматические сценарии
|
||||||
|
|
||||||
|
- `Готово к доставке` → бот отправляет предложение со слотами.
|
||||||
|
- `Подтверждение клиента` → статус `Доставка согласована`, согласование `Подтверждено клиентом`.
|
||||||
|
- `Перенос` → создаётся новый `delivery_slot`, статус `Ожидает согласования доставки`, согласование `Перенос запрошен`.
|
||||||
|
- `Отмена` или сбой сценария → статус `Проблема доставки`.
|
||||||
|
- `Нет ответа` после SLA → согласование `Нет ответа` и задача логисту на ручную обработку.
|
||||||
|
|
||||||
|
## Надёжность и аудит
|
||||||
|
|
||||||
|
- Все inbound/outbound события нужно логировать с `external_message_id`.
|
||||||
|
- Ошибки интеграции стоит писать в отдельную таблицу `integration_events` или `error_logs`.
|
||||||
|
- Повторная обработка webhook должна быть идемпотентной по `external_message_id`.
|
||||||
|
|
||||||
|
## Что уже добавлено в репозиторий
|
||||||
|
|
||||||
|
- `supabase/functions/chatbot-webhook/index.ts` — приём webhook и фиксация inbound-событий.
|
||||||
|
- `supabase/functions/send-chatbot-message/index.ts` — отправка outbound-сообщений и логирование.
|
||||||
|
- `supabase/functions/_shared/chatbot.ts` — общий слой нормализации событий и статусов.
|
||||||
|
- `supabase/functions/_shared/workflow.ts` — чистая карта переходов для inbound/outbound событий.
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Сценарии работы приложения
|
||||||
|
|
||||||
|
## 1. Менеджер создаёт заказ
|
||||||
|
|
||||||
|
1. Менеджер входит по email и OTP.
|
||||||
|
2. Создаёт заказ с клиентом, адресом, каналом связи и комментарием.
|
||||||
|
3. Заказ получает статус `Новый`.
|
||||||
|
4. После проверки менеджер переводит его в `Подтверждён`.
|
||||||
|
5. Затем переводит в `В очереди производства`.
|
||||||
|
|
||||||
|
## 2. Производство запускает заказ
|
||||||
|
|
||||||
|
1. Начальник производства открывает очередь.
|
||||||
|
2. Переводит заказ в `В производстве`.
|
||||||
|
3. По завершении меняет статус на `Готово к доставке`.
|
||||||
|
4. В `order_history` фиксируются пользователь, время и переход статусов.
|
||||||
|
|
||||||
|
## 3. Логист согласует доставку через чатбот
|
||||||
|
|
||||||
|
1. Логист видит заказ со статусом `Готово к доставке`.
|
||||||
|
2. Система или логист отправляет сообщение в VK, Telegram или Messenger Max.
|
||||||
|
3. Статус меняется на `Согласование с клиентом через чатбот`.
|
||||||
|
4. Клиент подтверждает слот.
|
||||||
|
5. Статус переходит в `Доставка запланирована`.
|
||||||
|
|
||||||
|
## 4. Клиент переносит доставку
|
||||||
|
|
||||||
|
1. Клиент жмёт кнопку переноса.
|
||||||
|
2. Бот предлагает новые слоты.
|
||||||
|
3. Выбранный слот сохраняется в `delivery_slots`.
|
||||||
|
4. В заказе фиксируется новый статус `Доставка перенесена`.
|
||||||
|
5. Логист и менеджер получают уведомление.
|
||||||
|
|
||||||
|
## 5. Исключение
|
||||||
|
|
||||||
|
1. Если клиент не отвечает в течение SLA, система создаёт исключение.
|
||||||
|
2. Статус становится `Исключение: отсутствие ответа клиента`.
|
||||||
|
3. Администратор видит инцидент в панели аудита.
|
||||||
|
4. После ручной обработки логист может снова отправить сообщение или отменить доставку.
|
||||||
|
|
||||||
|
## 6. Завершение доставки
|
||||||
|
|
||||||
|
1. Логист отмечает фактическую доставку.
|
||||||
|
2. Заказ получает статус `Доставка завершена`.
|
||||||
|
3. В истории появляется финальная запись, а чат закрывается для активных действий.
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Demo Workflow Refresh 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:** Обновить demo-режим под согласованную модель ролей, статусов заказа, статусов согласования доставки и этапных уведомлений.
|
||||||
|
|
||||||
|
**Architecture:** Демо-данные остаются главным источником состояния в UI, но статусная модель выносится в константы и сервисные хелперы, чтобы моковые сценарии, таблицы, карточка заказа и панель уведомлений использовали одну и ту же бизнес-логику. UI не получает новые backend-зависимости: меняются только локальные данные, чистые функции и React-компоненты.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, Vite, Vitest, demo state via hooks, existing UI kit.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Workflow Model
|
||||||
|
|
||||||
|
### Task 1: Зафиксировать новую статусную модель в тестах и константах
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/services/orderService.test.js`
|
||||||
|
- Modify: `src/constants/orderStatuses.js`
|
||||||
|
- Create: `src/constants/deliveryWorkflow.js`
|
||||||
|
|
||||||
|
- [ ] Добавить тесты на новую основную линейку статусов, метрики и доступные переходы.
|
||||||
|
- [ ] Прогнать только `orderService` тесты и убедиться, что они падают по старой модели.
|
||||||
|
- [ ] Вынести статусы, комментарии и переходы в workflow-константы.
|
||||||
|
- [ ] Повторно прогнать `orderService` тесты.
|
||||||
|
|
||||||
|
## Chunk 2: Demo State
|
||||||
|
|
||||||
|
### Task 2: Перевести моковые заказы, пользователей и уведомления на новую схему
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/data/mockAppData.js`
|
||||||
|
- Modify: `src/services/orderService.js`
|
||||||
|
- Modify: `src/hooks/useOrders.js`
|
||||||
|
|
||||||
|
- [ ] Добавить роль водителя и новые demo-заказы с разными этапами процесса.
|
||||||
|
- [ ] Добавить в заказы статус согласования доставки, пояснения и этапные уведомления.
|
||||||
|
- [ ] Обновить сервисные функции создания заказа, смены статуса, метрик и уведомлений.
|
||||||
|
- [ ] Прогнать таргетированные тесты на демо-логику.
|
||||||
|
|
||||||
|
## Chunk 3: UI
|
||||||
|
|
||||||
|
### Task 3: Поднять интерфейс под новую модель
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/DashboardPage.jsx`
|
||||||
|
- Modify: `src/components/orders/OrderDetailPanel.jsx`
|
||||||
|
- Modify: `src/components/orders/OrdersTable.jsx`
|
||||||
|
- Modify: `src/components/dashboard/ProductionQueuePanel.jsx`
|
||||||
|
- Modify: `src/components/logistics/BotControlPanel.jsx`
|
||||||
|
- Modify: `src/constants/roles.js`
|
||||||
|
|
||||||
|
- [ ] Обновить реестр, канбан и производственную панель под новые статусы.
|
||||||
|
- [ ] Показать в карточке заказа комментарий к статусу, статус согласования доставки и доступные переходы по роли.
|
||||||
|
- [ ] Подтянуть новую роль водителя и актуальные подписи разделов.
|
||||||
|
- [ ] Актуализировать демо-уведомления на экране обзора.
|
||||||
|
|
||||||
|
## Chunk 4: Verification
|
||||||
|
|
||||||
|
### Task 4: Полная проверка
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No code changes expected
|
||||||
|
|
||||||
|
- [ ] Запустить `npm test -- --watchAll=false`.
|
||||||
|
- [ ] Запустить `npm run lint`.
|
||||||
|
- [ ] Запустить `npm run build`.
|
||||||
|
- [ ] Кратко зафиксировать, что именно изменилось в demo-режиме и что ещё осталось вне объёма.
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
# Orders UI 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:** Упростить раздел `Заказы`: вынести создание заказа в отдельное действие, заменить псевдо-календарь на настоящий календарный вид, разнести архив и исключения, скрывать завершённые заказы по умолчанию.
|
||||||
|
|
||||||
|
**Architecture:** Логику отбора активных/завершённых/исключений и канбан-колонок вынести в отдельные чистые helper-функции с тестами. UI `Заказов` перестроить вокруг четырёх понятных вкладок: `Реестр`, `Календарь`, `Канбан`, `Архив`, а создание заказа открыть через модальное окно вместо постоянной тяжёлой формы в рабочей области.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, Vite, Vitest, existing UI components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: View Logic
|
||||||
|
|
||||||
|
### Task 1: Вынести и покрыть тестами правила отображения заказов
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/services/orderViews.js`
|
||||||
|
- Create: `src/services/orderViews.test.js`
|
||||||
|
|
||||||
|
- [ ] Написать failing tests на скрытие завершённых заказов, отдельную колонку исключений и архив.
|
||||||
|
- [ ] Прогнать таргетированный тест и увидеть корректный red-state.
|
||||||
|
- [ ] Реализовать минимальные helper-функции.
|
||||||
|
- [ ] Повторно прогнать тест.
|
||||||
|
|
||||||
|
## Chunk 2: Data and UI
|
||||||
|
|
||||||
|
### Task 2: Перестроить раздел `Заказы`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/DashboardPage.jsx`
|
||||||
|
- Modify: `src/data/mockAppData.js`
|
||||||
|
- Modify: `src/components/orders/OrderEditorPanel.jsx`
|
||||||
|
- Create: `src/components/orders/OrdersCalendarView.jsx`
|
||||||
|
|
||||||
|
- [ ] Добавить demo-заказ в архивный финальный статус.
|
||||||
|
- [ ] Убрать вкладку `Оформление`, добавить вкладку `Архив`.
|
||||||
|
- [ ] В `Реестре` добавить кнопку `Добавить заказ`, открывающую модалку.
|
||||||
|
- [ ] Убрать постоянный editor из рабочей области заказа.
|
||||||
|
- [ ] Заменить текущий календарь на месячную сетку.
|
||||||
|
- [ ] В канбане выделить `Исключения` в отдельную колонку и скрывать завершённые по умолчанию.
|
||||||
|
|
||||||
|
## Chunk 3: Verification
|
||||||
|
|
||||||
|
### Task 3: Полная проверка
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- No code changes expected
|
||||||
|
|
||||||
|
- [ ] Запустить `npm test`.
|
||||||
|
- [ ] Запустить `npm run lint`.
|
||||||
|
- [ ] Запустить `npm run build`.
|
||||||
|
- [ ] Зафиксировать, что именно стало проще для заказчика в демонстрации.
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Driver Route Workspace 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:** Сделать для водителя понятный экран плана доставок с фильтрами по датам, городу и половине дня, ручной перестановкой очередности и упрощённой карточкой доставки.
|
||||||
|
|
||||||
|
**Architecture:** Добавить отдельный набор helper-функций для фильтрации и группировки водительских доставок, затем собрать для роли водителя отдельный UI-модуль планировщика и упрощённую карточку. Навигацию роли водителя сузить до обзорного экрана и раздела доставок, чтобы убрать лишнюю сложность.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, Vite, локальное состояние через hooks, Vitest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: Driver planning helpers
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/services/driverDeliveries.js`
|
||||||
|
- Test: `src/services/driverDeliveries.test.js`
|
||||||
|
- Modify: `src/data/mockAppData.js`
|
||||||
|
|
||||||
|
- [ ] Добавить тесты на фильтрацию водительских доставок по диапазону дат, городу, половине дня и режиму просмотра.
|
||||||
|
- [ ] Добавить тест на группировку доставок по дням с сортировкой по ручному маршруту.
|
||||||
|
- [ ] Добавить тест на перестановку порядка доставок внутри одного дня.
|
||||||
|
- [ ] Реализовать helper-функции для фильтрации, группировки и перестановки.
|
||||||
|
- [ ] Обогатить демоданные полями порядка маршрута и достаточным количеством назначенных доставок для показа сценария.
|
||||||
|
|
||||||
|
## Chunk 2: Driver workspace UI
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/driver/DriverDeliveryPlanner.jsx`
|
||||||
|
- Create: `src/components/driver/DriverDeliveryDetail.jsx`
|
||||||
|
- Modify: `src/pages/DashboardPage.jsx`
|
||||||
|
|
||||||
|
- [ ] Собрать новый экран `Мои доставки` с фильтрами, дневными секциями и карточками.
|
||||||
|
- [ ] Добавить ручную перестановку карточек внутри дня.
|
||||||
|
- [ ] Показать на карточке только полезные водителю данные: адрес, клиент, телефон, окно доставки, состав, комментарий логиста, статус.
|
||||||
|
- [ ] Сделать отдельную простую карточку доставки для водителя вместо общего перегруженного order detail.
|
||||||
|
|
||||||
|
## Chunk 3: State wiring and simplification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/hooks/useOrders.js`
|
||||||
|
- Modify: `src/services/orderService.js`
|
||||||
|
- Modify: `src/pages/DashboardPage.jsx`
|
||||||
|
- Modify: `src/components/dashboard/RoleWorkspacePanel.jsx`
|
||||||
|
|
||||||
|
- [ ] Добавить действие для сохранения порядка маршрута в demo-state.
|
||||||
|
- [ ] Сузить навигацию роли водителя до реально нужных разделов.
|
||||||
|
- [ ] Сделать overview для водителя более прикладным и связанным с текущими доставками.
|
||||||
|
- [ ] Проверить, что быстрые действия водителя по статусам по-прежнему работают.
|
||||||
|
|
||||||
|
## Chunk 4: Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `src/services/driverDeliveries.test.js`
|
||||||
|
- Test: `src/services/orderService.test.js`
|
||||||
|
|
||||||
|
- [ ] Прогнать `npm test`.
|
||||||
|
- [ ] Прогнать `npm run lint`.
|
||||||
|
- [ ] Прогнать `npm run build`.
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
# PWA Demo Dashboard 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:** Сделать приложение installable PWA и добавить на дашборд панель, которая объясняет PWA и офлайн-работу demo-режима.
|
||||||
|
|
||||||
|
**Architecture:** PWA-слой реализуется вручную поверх текущего Vite-приложения: `public/manifest.webmanifest`, `public/service-worker.js` и регистрация service worker в `src/main.jsx`. Отдельный клиентский хук инкапсулирует онлайн-статус, установку и готовность PWA, а UI-панель на обзорном дашборде читает этот статус и показывает объяснение ограничений офлайн-demo.
|
||||||
|
|
||||||
|
**Tech Stack:** React 18, Vite 6, Vitest, existing UI kit, browser Service Worker / PWA APIs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Chunk 1: PWA Infrastructure
|
||||||
|
|
||||||
|
### Task 1: Зафиксировать требования к PWA-статусу в тестах
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/hooks/usePwaStatus.test.jsx`
|
||||||
|
- Create: `src/test/pwaTestUtils.js`
|
||||||
|
|
||||||
|
- [ ] Написать падающие тесты для хука `usePwaStatus` на:
|
||||||
|
- начальный онлайн-статус из `navigator.onLine`
|
||||||
|
- переключение при событиях `online` / `offline`
|
||||||
|
- появление install prompt после `beforeinstallprompt`
|
||||||
|
- определение standalone-режима через `matchMedia("(display-mode: standalone)")`
|
||||||
|
- [ ] Прогнать только новый набор тестов.
|
||||||
|
- Run: `npm test -- src/hooks/usePwaStatus.test.jsx`
|
||||||
|
- Expected: FAIL из-за отсутствующего хука и/или test utils
|
||||||
|
- [ ] Добавить минимальные test utils для стабов `matchMedia`, `BeforeInstallPromptEvent`-подобного объекта и очистки listeners.
|
||||||
|
- [ ] Повторно прогнать тесты и убедиться, что они всё ещё падают уже по ожидаемой причине, связанной с отсутствием реализации.
|
||||||
|
|
||||||
|
### Task 2: Реализовать PWA runtime state
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/hooks/usePwaStatus.js`
|
||||||
|
- Modify: `src/main.jsx`
|
||||||
|
|
||||||
|
- [ ] Реализовать `usePwaStatus` с полями:
|
||||||
|
- `isOnline`
|
||||||
|
- `isInstallAvailable`
|
||||||
|
- `isInstalled`
|
||||||
|
- `isServiceWorkerSupported`
|
||||||
|
- `isOfflineReady`
|
||||||
|
- `installApp`
|
||||||
|
- [ ] Добавить регистрацию `service-worker.js` в `src/main.jsx` только в production/browser-контексте.
|
||||||
|
- [ ] Передать в хук признак готовности service worker через событие `controllerchange`, `message` или локальное состояние регистрации.
|
||||||
|
- [ ] Прогнать таргетированные тесты.
|
||||||
|
- Run: `npm test -- src/hooks/usePwaStatus.test.jsx`
|
||||||
|
- Expected: PASS
|
||||||
|
|
||||||
|
### Task 3: Добавить manifest, service worker и статические PWA-ресурсы
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `public/manifest.webmanifest`
|
||||||
|
- Create: `public/service-worker.js`
|
||||||
|
- Create: `public/icons/icon-192.svg`
|
||||||
|
- Create: `public/icons/icon-512.svg`
|
||||||
|
- Modify: `index.html`
|
||||||
|
|
||||||
|
- [ ] Добавить manifest с русскими именами приложения, `display: "standalone"`, theme/background colors и иконками.
|
||||||
|
- [ ] Подключить manifest и базовые meta tags в `index.html`.
|
||||||
|
- [ ] Реализовать `public/service-worker.js` с:
|
||||||
|
- версионированным cache name
|
||||||
|
- precache app shell (`/`, `/index.html`, manifest, icons)
|
||||||
|
- navigation fallback на `/index.html`
|
||||||
|
- cache-first для same-origin статических ассетов
|
||||||
|
- сообщением клиенту о готовности офлайн-оболочки
|
||||||
|
- [ ] Добавить простые SVG-иконки, чтобы build/install не зависели от внешних файлов.
|
||||||
|
- [ ] Прогнать сборку.
|
||||||
|
- Run: `npm run build`
|
||||||
|
- Expected: PASS, в `dist/` появляются manifest и service worker
|
||||||
|
|
||||||
|
## Chunk 2: Dashboard Demo Panel
|
||||||
|
|
||||||
|
### Task 4: Зафиксировать UI-поведение панели в тестах
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/components/dashboard/PwaDemoPanel.test.jsx`
|
||||||
|
- Create: `src/components/dashboard/PwaDemoPanel.jsx`
|
||||||
|
|
||||||
|
- [ ] Написать падающие тесты для панели на сценарии:
|
||||||
|
- показывает объяснение PWA и demo-режима на русском
|
||||||
|
- показывает online/offline badge
|
||||||
|
- показывает install CTA только когда установка доступна
|
||||||
|
- показывает ограничения для backend-интеграций
|
||||||
|
- [ ] Прогнать только тест панели.
|
||||||
|
- Run: `npm test -- src/components/dashboard/PwaDemoPanel.test.jsx`
|
||||||
|
- Expected: FAIL из-за отсутствующего компонента
|
||||||
|
- [ ] Реализовать минимальный `PwaDemoPanel` на `Panel`, `Badge`, `Button`.
|
||||||
|
- [ ] Повторно прогнать тест панели.
|
||||||
|
- Run: `npm test -- src/components/dashboard/PwaDemoPanel.test.jsx`
|
||||||
|
- Expected: PASS
|
||||||
|
|
||||||
|
### Task 5: Встроить панель в обзор дашборда
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/pages/DashboardPage.jsx`
|
||||||
|
- Optionally Modify: `src/components/UI/Badge.jsx` if понадобится новый tone
|
||||||
|
|
||||||
|
- [ ] Подключить `usePwaStatus` в `DashboardPage`.
|
||||||
|
- [ ] Разместить `PwaDemoPanel` в обзорной секции рядом с KPI/оперативными блоками без поломки существующей сетки.
|
||||||
|
- [ ] Убедиться, что панель рендерится только для авторизованного пользователя и не влияет на другие разделы.
|
||||||
|
- [ ] При необходимости добавить новый тон badge для позитивного статуса вместо инлайновых классов.
|
||||||
|
- [ ] Прогнать таргетированные тесты хука и панели вместе.
|
||||||
|
- Run: `npm test -- src/hooks/usePwaStatus.test.jsx src/components/dashboard/PwaDemoPanel.test.jsx`
|
||||||
|
- Expected: PASS
|
||||||
|
|
||||||
|
## Chunk 3: Verification and Hardening
|
||||||
|
|
||||||
|
### Task 6: Проверить интеграцию вручную и автоматикой
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`
|
||||||
|
- Optionally Modify: `docs/architecture.md`
|
||||||
|
|
||||||
|
- [ ] Обновить README кратким описанием PWA/demo offline behavior.
|
||||||
|
- [ ] При необходимости добавить в `docs/architecture.md` модуль `usePwaStatus` и описание PWA-оболочки.
|
||||||
|
- [ ] Запустить полный тестовый набор.
|
||||||
|
- Run: `npm test`
|
||||||
|
- Expected: PASS
|
||||||
|
- [ ] Запустить линтер.
|
||||||
|
- Run: `npm run lint`
|
||||||
|
- Expected: PASS
|
||||||
|
- [ ] Запустить production build.
|
||||||
|
- Run: `npm run build`
|
||||||
|
- Expected: PASS
|
||||||
|
- [ ] Выполнить ручную проверку:
|
||||||
|
- открыть приложение онлайн
|
||||||
|
- войти в demo-режиме
|
||||||
|
- убедиться, что сервис-воркер зарегистрирован
|
||||||
|
- перевести браузер offline
|
||||||
|
- перезагрузить `/dashboard` и убедиться, что оболочка и demo-данные остаются доступны
|
||||||
|
- проверить install CTA в поддерживаемом браузере
|
||||||
|
- [ ] Зафиксировать итог коротким комментарием в ответе пользователю: что работает офлайн, а что остаётся сетевым ограничением.
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
# PWA Demo Dashboard Design
|
||||||
|
|
||||||
|
**Date:** 2026-03-14
|
||||||
|
|
||||||
|
**Goal:** Turn the app into an installable PWA and add a dashboard panel that explains what PWA is, why it matters for demo mode, and how the offline demo behaves.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- The app already has a demo mode that works without Supabase configuration.
|
||||||
|
- Demo login state is stored locally in `localStorage`.
|
||||||
|
- Demo orders, notifications, and users are already shipped with the frontend bundle.
|
||||||
|
- Git history shows an earlier `public/service-worker.js`, but the current workspace no longer contains the PWA assets.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Implement a manual PWA layer for the Vite app and pair it with a dashboard-only "Demo and PWA" panel.
|
||||||
|
|
||||||
|
This keeps the solution dependency-light, restores full control over caching behavior, and lets the demo flow work offline after the first successful load.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### PWA shell
|
||||||
|
|
||||||
|
- Add a `public/` directory with:
|
||||||
|
- `manifest.webmanifest`
|
||||||
|
- `service-worker.js`
|
||||||
|
- app icons for install surfaces
|
||||||
|
- Register the service worker from `src/main.jsx`.
|
||||||
|
- Cache the app shell with a conservative strategy:
|
||||||
|
- precache the shell entrypoints and icons during `install`
|
||||||
|
- serve navigation requests with an app-shell fallback
|
||||||
|
- serve same-origin static assets from cache first, then network
|
||||||
|
|
||||||
|
### Runtime state
|
||||||
|
|
||||||
|
- Add a small frontend service/hook for:
|
||||||
|
- service worker registration status
|
||||||
|
- online/offline state
|
||||||
|
- install prompt availability
|
||||||
|
- installed/running-standalone detection
|
||||||
|
- Keep this state client-only and independent from the auth/data hooks.
|
||||||
|
|
||||||
|
### Dashboard experience
|
||||||
|
|
||||||
|
- Add a dedicated dashboard panel in the overview section named around "Демо и PWA".
|
||||||
|
- The panel explains:
|
||||||
|
- what PWA is
|
||||||
|
- why installability matters for demos
|
||||||
|
- what works offline in demo mode
|
||||||
|
- what still requires network and real backend integration
|
||||||
|
- Show compact status chips for:
|
||||||
|
- online/offline
|
||||||
|
- app install availability / installed state
|
||||||
|
- offline readiness after service worker activation
|
||||||
|
- Show an install CTA only when the browser exposes an install prompt.
|
||||||
|
|
||||||
|
## Offline behavior
|
||||||
|
|
||||||
|
### Guaranteed offline
|
||||||
|
|
||||||
|
- Opening the installed app after it has already been loaded once
|
||||||
|
- Reopening `/dashboard` without network
|
||||||
|
- Demo login flow with local role selection and OTP `000000`
|
||||||
|
- Viewing demo orders, metrics, notifications, and role-based dashboard sections backed by local data
|
||||||
|
|
||||||
|
### Not guaranteed offline
|
||||||
|
|
||||||
|
- Supabase-backed auth and profile loading
|
||||||
|
- Any future remote integrations, push delivery, or server-dependent writes
|
||||||
|
- Fresh first load before the service worker has cached the shell
|
||||||
|
|
||||||
|
The dashboard copy must state these limits plainly to avoid overpromising.
|
||||||
|
|
||||||
|
## UI content
|
||||||
|
|
||||||
|
The dashboard panel should communicate in Russian and stay concise:
|
||||||
|
|
||||||
|
- short explanation of PWA in plain language
|
||||||
|
- short explanation of why it is useful in demos
|
||||||
|
- explicit note that demo data is local, so the product can be shown without internet after first launch
|
||||||
|
- explicit note that production integrations need connectivity
|
||||||
|
|
||||||
|
## File responsibilities
|
||||||
|
|
||||||
|
- `public/manifest.webmanifest` — install metadata
|
||||||
|
- `public/service-worker.js` — shell caching and offline fallback behavior
|
||||||
|
- `public/icons/*` — install and launcher icons
|
||||||
|
- `src/main.jsx` — service worker registration
|
||||||
|
- `src/hooks/usePwaStatus.js` — browser/PWA state
|
||||||
|
- `src/components/dashboard/PwaDemoPanel.jsx` — dashboard explanation and status UI
|
||||||
|
- `src/pages/DashboardPage.jsx` — placement of the panel in overview
|
||||||
|
- tests next to the new hook/component where it is practical
|
||||||
|
|
||||||
|
## Testing strategy
|
||||||
|
|
||||||
|
- Add targeted tests for the new hook/component logic where feasible in Vitest.
|
||||||
|
- Manually verify:
|
||||||
|
- build succeeds
|
||||||
|
- manifest is emitted and linked
|
||||||
|
- service worker registers
|
||||||
|
- install prompt appears in a supported browser
|
||||||
|
- dashboard is available offline after one online load
|
||||||
|
- the explanatory panel reflects online/offline and install states correctly
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Push notification recovery or subscription flows
|
||||||
|
- Full offline write synchronization to a server
|
||||||
|
- Refactoring unrelated dashboard architecture
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
import js from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import react from "eslint-plugin-react";
|
||||||
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ["dist/**", "node_modules/**"],
|
||||||
|
},
|
||||||
|
js.configs.recommended,
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,jsx}"],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
parserOptions: {
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
globals: {
|
||||||
|
...globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
react,
|
||||||
|
"react-hooks": reactHooks,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
varsIgnorePattern: "^React$",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"react/react-in-jsx-scope": "off",
|
||||||
|
"react/jsx-uses-vars": "error",
|
||||||
|
"react/prop-types": "off",
|
||||||
|
"react-hooks/rules-of-hooks": "error",
|
||||||
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: "detect",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#12805c" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Демо-панель управления доставкой и заказами с офлайн-доступом после первого запуска."
|
||||||
|
/>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/icons/icon-192.svg" />
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest" />
|
||||||
|
<title>Construction Delivery Control</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"name": "construction-delivery",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext js,jsx",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.52.0",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"framer-motion": "^12.7.4",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.3.0",
|
||||||
|
"tailwind-merge": "^3.3.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.22.0",
|
||||||
|
"@types/react": "^18.3.18",
|
||||||
|
"@types/react-dom": "^18.3.5",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"eslint": "^9.22.0",
|
||||||
|
"eslint-plugin-react": "^7.37.5",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"vite": "^6.2.0",
|
||||||
|
"vitest": "^3.0.9"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
||||||
|
<rect width="192" height="192" rx="44" fill="#12805c" />
|
||||||
|
<path d="M45 57h67c12 0 22 10 22 22v31c0 14-11 25-25 25H74c-16 0-29-13-29-29V57Z" fill="#f4fffb" opacity=".98" />
|
||||||
|
<path d="M111 75h17c10 0 19 8 19 19v16c0 10-9 19-19 19h-10" fill="none" stroke="#f4fffb" stroke-linecap="round" stroke-linejoin="round" stroke-width="10" />
|
||||||
|
<path d="M67 87h35M67 106h48" fill="none" stroke="#12805c" stroke-linecap="round" stroke-width="10" />
|
||||||
|
<circle cx="75" cy="132" r="10" fill="#12805c" />
|
||||||
|
<circle cx="122" cy="132" r="10" fill="#12805c" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 614 B |
|
|
@ -0,0 +1,8 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="124" fill="#12805c" />
|
||||||
|
<path d="M119 152h180c33 0 59 26 59 59v84c0 38-31 69-69 69h-95c-42 0-75-33-75-75V152Z" fill="#f4fffb" opacity=".98" />
|
||||||
|
<path d="M296 201h48c29 0 51 23 51 52v39c0 28-22 51-51 51h-29" fill="none" stroke="#f4fffb" stroke-linecap="round" stroke-linejoin="round" stroke-width="27" />
|
||||||
|
<path d="M177 234h94M177 283h128" fill="none" stroke="#12805c" stroke-linecap="round" stroke-width="27" />
|
||||||
|
<circle cx="198" cy="355" r="27" fill="#12805c" />
|
||||||
|
<circle cx="324" cy="355" r="27" fill="#12805c" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 628 B |
|
|
@ -0,0 +1,25 @@
|
||||||
|
{
|
||||||
|
"name": "Школьное питание Demo",
|
||||||
|
"short_name": "Школьное питание",
|
||||||
|
"description": "PWA-демо панели заказов и доставки с офлайн-доступом после первого запуска.",
|
||||||
|
"start_url": "/dashboard",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"background_color": "#f4f7f5",
|
||||||
|
"theme_color": "#12805c",
|
||||||
|
"lang": "ru",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-192.svg",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/icon-512.svg",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
const STATIC_CACHE = "construction-delivery-static-v1";
|
||||||
|
const RUNTIME_CACHE = "construction-delivery-runtime-v1";
|
||||||
|
const APP_SHELL_URLS = ["/", "/index.html", "/manifest.webmanifest", "/icons/icon-192.svg", "/icons/icon-512.svg"];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(STATIC_CACHE).then((cache) => cache.addAll(APP_SHELL_URLS)).then(() => self.skipWaiting()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches
|
||||||
|
.keys()
|
||||||
|
.then((keys) =>
|
||||||
|
Promise.all(
|
||||||
|
keys
|
||||||
|
.filter((key) => ![STATIC_CACHE, RUNTIME_CACHE].includes(key))
|
||||||
|
.map((key) => caches.delete(key)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then(() => self.clients.claim())
|
||||||
|
.then(async () => {
|
||||||
|
const clients = await self.clients.matchAll({ includeUncontrolled: true });
|
||||||
|
clients.forEach((client) => client.postMessage({ type: "PWA_OFFLINE_READY" }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
if (event.request.method !== "GET") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestUrl = new URL(event.request.url);
|
||||||
|
const isSameOrigin = requestUrl.origin === self.location.origin;
|
||||||
|
|
||||||
|
if (event.request.mode === "navigate") {
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request)
|
||||||
|
.then((response) => {
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(RUNTIME_CACHE).then((cache) => cache.put(event.request, responseClone));
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(async () => {
|
||||||
|
const cachedPage = await caches.match(event.request);
|
||||||
|
return cachedPage || caches.match("/index.html");
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSameOrigin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.respondWith(
|
||||||
|
caches.match(event.request).then((cachedResponse) => {
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fetch(event.request).then((response) => {
|
||||||
|
if (!response || response.status !== 200) {
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseClone = response.clone();
|
||||||
|
caches.open(RUNTIME_CACHE).then((cache) => cache.put(event.request, responseClone));
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Outlet } from "react-router-dom";
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
return <Outlet />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "../../lib/cn";
|
||||||
|
|
||||||
|
export const Badge = ({ children, tone = "neutral" }) => {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"inline-flex rounded-full px-3 py-1 text-xs font-semibold",
|
||||||
|
{
|
||||||
|
"bg-[var(--color-accent-soft)] text-[var(--color-accent)]": tone === "accent",
|
||||||
|
"bg-[rgba(201,61,61,0.12)] text-[var(--color-danger)]": tone === "danger",
|
||||||
|
"bg-[rgba(191,123,33,0.12)] text-[var(--color-warning)]": tone === "warning",
|
||||||
|
"bg-[var(--color-surface-strong)] text-[var(--color-text-muted)]": tone === "neutral",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "../../lib/cn";
|
||||||
|
|
||||||
|
export const Button = ({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
variant = "primary",
|
||||||
|
size = "md",
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center rounded-full border transition duration-150",
|
||||||
|
"focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-transparent",
|
||||||
|
{
|
||||||
|
"border-transparent bg-[var(--color-accent)] text-[var(--color-accent-contrast)] hover:opacity-90":
|
||||||
|
variant === "primary",
|
||||||
|
"border-[var(--color-border)] bg-[var(--color-surface)] text-[var(--color-text)] hover:bg-[var(--color-accent-soft)]":
|
||||||
|
variant === "secondary",
|
||||||
|
"border-[var(--color-border)] bg-transparent text-[var(--color-text-muted)] hover:text-[var(--color-text)]":
|
||||||
|
variant === "ghost",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"px-5 py-2 text-sm": size === "sm",
|
||||||
|
"px-6 py-3 text-sm": size === "md",
|
||||||
|
},
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "../../lib/cn";
|
||||||
|
|
||||||
|
export const Input = ({ className, ...props }) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={cn(
|
||||||
|
"w-full rounded-2xl border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3",
|
||||||
|
"text-sm text-[var(--color-text)] placeholder:text-[var(--color-text-muted)]",
|
||||||
|
"focus:border-[var(--color-accent)] focus:outline-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "../../lib/cn";
|
||||||
|
|
||||||
|
export const Modal = ({ children, isOpen, onClose, className }) => {
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleEscape = (event) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handleEscape);
|
||||||
|
return () => window.removeEventListener("keydown", handleEscape);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[rgba(9,17,15,0.45)] p-4">
|
||||||
|
<div className="absolute inset-0" onClick={onClose} />
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative max-h-[92vh] w-full max-w-[1220px] overflow-y-auto rounded-[32px] border border-[var(--color-border)] bg-[var(--color-base)] p-4 shadow-soft md:p-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "../../lib/cn";
|
||||||
|
|
||||||
|
export const Panel = ({ children, className }) => {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={cn(
|
||||||
|
"rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-5 shadow-soft backdrop-blur",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "../../lib/cn";
|
||||||
|
|
||||||
|
export const SegmentedTabs = ({ items, activeKey, onChange, className }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"inline-flex flex-wrap gap-2 rounded-[28px] border border-[var(--color-border)] bg-[var(--color-surface)] p-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
className={cn(
|
||||||
|
"rounded-[20px] px-4 py-3 text-sm transition",
|
||||||
|
activeKey === item.key
|
||||||
|
? "bg-[var(--color-accent)] text-[var(--color-accent-contrast)]"
|
||||||
|
: "text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]",
|
||||||
|
)}
|
||||||
|
onClick={() => onChange(item.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from "react";
|
||||||
|
import { cn } from "../../lib/cn";
|
||||||
|
|
||||||
|
export const Select = ({ className, children, ...props }) => {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
className={cn(
|
||||||
|
"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",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
import { useTheme } from "../../context/ThemeContext";
|
||||||
|
|
||||||
|
export const ThemeToggle = () => {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button variant="secondary" size="sm" onClick={toggleTheme}>
|
||||||
|
{theme === "light" ? "Тёмная тема" : "Светлая тема"}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,64 @@
|
||||||
|
import React from "react";
|
||||||
|
import { demoNotifications } from "../../data/mockAppData";
|
||||||
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
|
const AUDIT_TYPE_LABELS = {
|
||||||
|
success: "Успешно",
|
||||||
|
warning: "Внимание",
|
||||||
|
error: "Ошибка",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AuditPanel = ({ order }) => {
|
||||||
|
return (
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Логи и исключения</h3>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Журнал инцидентов, интеграций и последних действий по заказу.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{demoNotifications.map((item) => {
|
||||||
|
const normalizedType = String(item.type || "").toLowerCase();
|
||||||
|
const tone =
|
||||||
|
normalizedType === "error"
|
||||||
|
? "danger"
|
||||||
|
: normalizedType === "warning"
|
||||||
|
? "warning"
|
||||||
|
: "accent";
|
||||||
|
const label = AUDIT_TYPE_LABELS[normalizedType] || "Событие";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<Badge tone={tone}>{label}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">{item.description}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{order ? (
|
||||||
|
<div className="rounded-[22px] border border-[var(--color-border)] p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<strong>Последнее действие по заказу {order.orderNumber}</strong>
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
{formatDateTime(order.history[0]?.at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
{order.history[0]?.userName}: {order.history[0]?.action}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import React from "react";
|
||||||
|
import { ROLE_LABELS } from "../../constants/roles";
|
||||||
|
import { demoUsers } from "../../data/mockAppData";
|
||||||
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
|
export const UserDirectoryPanel = ({ currentUser }) => {
|
||||||
|
if (currentUser.role !== "admin") {
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<h3 className="text-lg font-semibold">Пользователи и роли</h3>
|
||||||
|
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Эта панель доступна администратору. Здесь можно просматривать сотрудников, роли и
|
||||||
|
активность входа.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">Пользователи и роли</h3>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Полный обзор доступа, ролей и последнего входа сотрудников.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{demoUsers.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3 rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{user.name}</div>
|
||||||
|
<div className="text-sm text-[var(--color-text-muted)]">{user.email}</div>
|
||||||
|
<div className="text-sm text-[var(--color-text-muted)]">{user.phone}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge tone="accent">{ROLE_LABELS[user.role]}</Badge>
|
||||||
|
<span className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Вход: {formatDateTime(user.lastLogin)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full text-sm text-[var(--color-text-muted)]">
|
||||||
|
Каналы: Телеграм {user.botBindings.telegram || "не привязан"} · ВКонтакте{" "}
|
||||||
|
{user.botBindings.vk || "не привязан"} · Макс{" "}
|
||||||
|
{user.botBindings.messengerMax || "не привязан"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import React from "react";
|
||||||
|
import { ROLE_LABELS } from "../../constants/roles";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
|
import { Input } from "../UI/Input";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Select } from "../UI/Select";
|
||||||
|
|
||||||
|
const defaultState = {
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
phone: "",
|
||||||
|
role: "manager",
|
||||||
|
botLinkMode: "phone",
|
||||||
|
telegram: "",
|
||||||
|
vk: "",
|
||||||
|
messengerMax: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const UserOnboardingPanel = () => {
|
||||||
|
const [form, setForm] = React.useState(defaultState);
|
||||||
|
const [drafts, setDrafts] = React.useState([]);
|
||||||
|
|
||||||
|
const updateField = (key, value) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddDraft = () => {
|
||||||
|
if (!form.name || !form.email || !form.phone) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDrafts((current) => [
|
||||||
|
{
|
||||||
|
id: Date.now(),
|
||||||
|
...form,
|
||||||
|
},
|
||||||
|
...current,
|
||||||
|
]);
|
||||||
|
setForm(defaultState);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="space-y-5 p-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Добавление пользователя и привязка к ботам</h3>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
Практичный сценарий: сотрудник добавляется по электронной почте и телефону, получает роль, после
|
||||||
|
чего к нему привязываются идентификаторы каналов. Для ботов лучше иметь два варианта
|
||||||
|
привязки: по номеру телефона и по имени пользователя или идентификатору в конкретном
|
||||||
|
мессенджере.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Имя сотрудника"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(event) => updateField("name", event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Электронная почта"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(event) => updateField("email", event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Телефон"
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(event) => updateField("phone", event.target.value)}
|
||||||
|
/>
|
||||||
|
<Select value={form.role} onChange={(event) => updateField("role", event.target.value)}>
|
||||||
|
{Object.entries(ROLE_LABELS).map(([roleKey, label]) => (
|
||||||
|
<option key={roleKey} value={roleKey}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={form.botLinkMode}
|
||||||
|
onChange={(event) => updateField("botLinkMode", event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="phone">Привязка по телефону</option>
|
||||||
|
<option value="account">Привязка по аккаунту или идентификатору</option>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
placeholder="Имя в Телеграме"
|
||||||
|
value={form.telegram}
|
||||||
|
onChange={(event) => updateField("telegram", event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Идентификатор ВКонтакте"
|
||||||
|
value={form.vk}
|
||||||
|
onChange={(event) => updateField("vk", event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Идентификатор в Максе"
|
||||||
|
value={form.messengerMax}
|
||||||
|
onChange={(event) => updateField("messengerMax", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleAddDraft}>Добавить в список приглашений</Button>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{drafts.map((draft) => (
|
||||||
|
<div
|
||||||
|
key={draft.id}
|
||||||
|
className="rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{draft.name}</div>
|
||||||
|
<div className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{draft.email} · {draft.phone} · {ROLE_LABELS[draft.role]}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Способ привязки:{" "}
|
||||||
|
{draft.botLinkMode === "phone" ? "по телефону" : "по аккаунту или идентификатору"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
import React from "react";
|
||||||
|
import { ROLE_LABELS } from "../../constants/roles";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
|
import { Input } from "../UI/Input";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Select } from "../UI/Select";
|
||||||
|
|
||||||
|
export const OtpLoginForm = ({
|
||||||
|
email,
|
||||||
|
setEmail,
|
||||||
|
roleHint,
|
||||||
|
setRoleHint,
|
||||||
|
otp,
|
||||||
|
setOtp,
|
||||||
|
isOtpSent,
|
||||||
|
isLoading,
|
||||||
|
isDemoMode,
|
||||||
|
onRequestOtp,
|
||||||
|
onVerifyOtp,
|
||||||
|
error,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Panel className="w-full max-w-md p-8">
|
||||||
|
<div className="mb-8 space-y-2">
|
||||||
|
<p className="text-sm uppercase tracking-[0.28em] text-[var(--color-text-muted)]">
|
||||||
|
Платформа доставки
|
||||||
|
</p>
|
||||||
|
<h1 className="text-3xl font-semibold">Управление заказами и доставкой</h1>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Вход по электронной почте и одноразовому коду. Права и рабочая область определяются
|
||||||
|
ролью пользователя.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="email">
|
||||||
|
Электронная почта
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
|
placeholder="Введите адрес электронной почты"
|
||||||
|
type="email"
|
||||||
|
disabled={isDemoMode}
|
||||||
|
/>
|
||||||
|
{isDemoMode ? (
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
Для демонстрации используется единый адрес входа.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!isOtpSent && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="roleHint">
|
||||||
|
Роль для демо-режима
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
id="roleHint"
|
||||||
|
value={roleHint}
|
||||||
|
onChange={(event) => setRoleHint(event.target.value)}
|
||||||
|
>
|
||||||
|
{Object.entries(ROLE_LABELS).map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOtpSent && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="otp">
|
||||||
|
Одноразовый код
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="otp"
|
||||||
|
value={otp}
|
||||||
|
onChange={(event) => setOtp(event.target.value)}
|
||||||
|
placeholder="6 цифр"
|
||||||
|
inputMode="numeric"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? <p className="text-sm text-[var(--color-danger)]">{error}</p> : null}
|
||||||
|
|
||||||
|
{!isOtpSent ? (
|
||||||
|
<Button className="w-full" onClick={onRequestOtp} disabled={isLoading || !email}>
|
||||||
|
{isLoading ? "Отправка..." : "Отправить код"}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button className="w-full" onClick={onVerifyOtp} disabled={isLoading || !otp}>
|
||||||
|
{isLoading ? "Проверка..." : "Подтвердить вход"}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-3xl bg-[var(--color-accent-soft)] p-4 text-sm text-[var(--color-text)]">
|
||||||
|
{isDemoMode
|
||||||
|
? "Демо-режим активен: выберите роль и используйте код 000000."
|
||||||
|
: "Подключена рабочая база данных: код отправляется на электронную почту пользователя."}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
import React from "react";
|
||||||
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
|
||||||
|
export const ChatTimeline = ({ messages }) => {
|
||||||
|
if (!messages.length) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[24px] border border-dashed border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Пока нет сообщений. Здесь появится история переписки с клиентом из ВКонтакте, Телеграма
|
||||||
|
и Макса.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{messages.map((message) => (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<Badge tone={message.sender === "client" ? "warning" : "accent"}>
|
||||||
|
{message.sender === "client" ? "Сообщение клиента" : "Сообщение бота"}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">
|
||||||
|
{message.channel} · {formatDateTime(message.sentAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-6 text-[var(--color-text)]">{message.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
|
export const KpiCard = ({ label, value, hint }) => {
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">{label}</p>
|
||||||
|
<div className="mt-4 flex items-end justify-between gap-4">
|
||||||
|
<span className="text-3xl font-semibold">{value}</span>
|
||||||
|
<span className="text-xs text-[var(--color-text-muted)]">{hint}</span>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import React from "react";
|
||||||
|
import { PRODUCTION_STATUSES } from "../../constants/deliveryWorkflow";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
|
export const ProductionQueuePanel = ({ orders }) => {
|
||||||
|
const grouped = PRODUCTION_STATUSES.map((stage) => ({
|
||||||
|
stage,
|
||||||
|
items: orders.filter((order) => order.status === stage),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="mb-4">
|
||||||
|
<h3 className="text-lg font-semibold">Очередь производства</h3>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Отдельная панель начальника производства с текущими этапами выполнения.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{grouped.map((column) => (
|
||||||
|
<div
|
||||||
|
key={column.stage}
|
||||||
|
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<strong>{column.stage}</strong>
|
||||||
|
<Badge tone="accent">{column.items.length}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{column.items.length ? (
|
||||||
|
column.items.map((order) => (
|
||||||
|
<div key={order.id} className="rounded-2xl bg-[var(--color-surface)] p-3">
|
||||||
|
<div className="font-medium text-[var(--color-text)]">{order.orderNumber}</div>
|
||||||
|
<div>{order.customer.name}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="rounded-2xl border border-dashed border-[var(--color-border)] p-3">
|
||||||
|
Нет заказов
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
|
const statusConfig = {
|
||||||
|
online: { label: "Онлайн", tone: "accent" },
|
||||||
|
offline: { label: "Офлайн", tone: "warning" },
|
||||||
|
installed: { label: "Установлено", tone: "accent" },
|
||||||
|
browser: { label: "В браузере", tone: "neutral" },
|
||||||
|
ready: { label: "Офлайн готов", tone: "accent" },
|
||||||
|
pending: { label: "Кешируется", tone: "neutral" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PwaDemoPanel = ({
|
||||||
|
isOnline,
|
||||||
|
isInstallAvailable,
|
||||||
|
isInstalled,
|
||||||
|
isOfflineReady,
|
||||||
|
onInstall,
|
||||||
|
}) => {
|
||||||
|
const networkBadge = isOnline ? statusConfig.online : statusConfig.offline;
|
||||||
|
const installBadge = isInstalled ? statusConfig.installed : statusConfig.browser;
|
||||||
|
const offlineBadge = isOfflineReady ? statusConfig.ready : statusConfig.pending;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-6">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
|
Демо и PWA
|
||||||
|
</p>
|
||||||
|
<h3 className="text-xl font-semibold">Что это и зачем для демонстрации</h3>
|
||||||
|
<p className="max-w-3xl text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
PWA превращает веб-приложение в устанавливаемый рабочий экран: его можно открыть как
|
||||||
|
отдельное приложение и использовать для показа сценариев после первого запуска.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isInstallAvailable ? (
|
||||||
|
<Button variant="secondary" onClick={onInstall}>
|
||||||
|
Установить приложение
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap gap-2">
|
||||||
|
<Badge tone={networkBadge.tone}>{networkBadge.label}</Badge>
|
||||||
|
<Badge tone={installBadge.tone}>{installBadge.label}</Badge>
|
||||||
|
<Badge tone={offlineBadge.tone}>{offlineBadge.label}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
||||||
|
<div className="rounded-[24px] bg-[var(--color-surface-strong)] p-5">
|
||||||
|
<h4 className="text-sm font-semibold uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
||||||
|
Что это
|
||||||
|
</h4>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
Это PWA-версия панели: её можно установить на ноутбук или телефон и запускать без
|
||||||
|
адресной строки браузера.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-[24px] bg-[var(--color-surface-strong)] p-5">
|
||||||
|
<h4 className="text-sm font-semibold uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
||||||
|
Зачем для демо
|
||||||
|
</h4>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
После первого запуска оболочка кешируется, поэтому дашборд и демо-данные можно
|
||||||
|
показать даже без интернета.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 rounded-[24px] border border-[var(--color-border)] bg-[var(--color-accent-soft)] p-5">
|
||||||
|
<p className="text-sm font-medium">Как работает офлайн-демо</p>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
Демо-данные доступны локально: роли, вход по коду `000000`, заказы, статусы и обзор
|
||||||
|
дашборда продолжают работать после первого запуска.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
Интеграции и рабочая база требуют подключения, поэтому Supabase и боевые сценарии
|
||||||
|
остаются сетевыми.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PwaDemoPanel } from "./PwaDemoPanel";
|
||||||
|
|
||||||
|
describe("PwaDemoPanel", () => {
|
||||||
|
it("renders Russian explanation for PWA demo mode", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<PwaDemoPanel
|
||||||
|
isInstallAvailable={true}
|
||||||
|
isInstalled={false}
|
||||||
|
isOfflineReady={true}
|
||||||
|
isOnline={true}
|
||||||
|
onInstall={() => {}}
|
||||||
|
/>,
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
|
expect(markup).toContain("что это");
|
||||||
|
expect(markup).toContain("pwa");
|
||||||
|
expect(markup).toContain("после первого запуска");
|
||||||
|
expect(markup).toContain("интеграции и рабочая база требуют подключения");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows install action only when installation is available", () => {
|
||||||
|
const availableMarkup = renderToStaticMarkup(
|
||||||
|
<PwaDemoPanel
|
||||||
|
isInstallAvailable={true}
|
||||||
|
isInstalled={false}
|
||||||
|
isOfflineReady={false}
|
||||||
|
isOnline={true}
|
||||||
|
onInstall={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const installedMarkup = renderToStaticMarkup(
|
||||||
|
<PwaDemoPanel
|
||||||
|
isInstallAvailable={false}
|
||||||
|
isInstalled={true}
|
||||||
|
isOfflineReady={true}
|
||||||
|
isOnline={true}
|
||||||
|
onInstall={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(availableMarkup).toContain("Установить приложение");
|
||||||
|
expect(installedMarkup).not.toContain("Установить приложение");
|
||||||
|
expect(installedMarkup).toContain("Установлено");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows offline badge when browser is offline", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<PwaDemoPanel
|
||||||
|
isInstallAvailable={false}
|
||||||
|
isInstalled={false}
|
||||||
|
isOfflineReady={true}
|
||||||
|
isOnline={false}
|
||||||
|
onInstall={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Офлайн");
|
||||||
|
expect(markup).toContain("Демо-данные доступны локально");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
import React from "react";
|
||||||
|
import { ROLE_LABELS } from "../../constants/roles";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
|
const ROLE_MODULES = {
|
||||||
|
manager: [
|
||||||
|
"Создание и подтверждение заказов",
|
||||||
|
"Поиск по клиенту, заказу и статусу",
|
||||||
|
"Комментарии и эскалации",
|
||||||
|
],
|
||||||
|
production_lead: [
|
||||||
|
"Очередь производства",
|
||||||
|
"Переключение статусов на производстве",
|
||||||
|
"Контроль готовности к отгрузке",
|
||||||
|
],
|
||||||
|
logistician: [
|
||||||
|
"Готовые заказы и слоты",
|
||||||
|
"Согласование через боты",
|
||||||
|
"Переносы, отмены, исключения",
|
||||||
|
],
|
||||||
|
driver: [
|
||||||
|
"Мои рейсы на сегодня",
|
||||||
|
"Подтверждение загрузки и выезда",
|
||||||
|
"Фиксация доставки и проблем",
|
||||||
|
],
|
||||||
|
admin: [
|
||||||
|
"Управление ролями и пользователями",
|
||||||
|
"Полный аудит истории и логов",
|
||||||
|
"Контроль интеграций и ошибок",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const RoleWorkspacePanel = ({ role }) => {
|
||||||
|
const modules = ROLE_MODULES[role] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{ROLE_LABELS[role]}: рабочая панель</h2>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Интерфейс автоматически адаптируется под роль пользователя после входа по одноразовому
|
||||||
|
коду.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="accent">{ROLE_LABELS[role]}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||||||
|
{modules.map((module) => (
|
||||||
|
<div
|
||||||
|
key={module}
|
||||||
|
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm"
|
||||||
|
>
|
||||||
|
{module}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,118 @@
|
||||||
|
import React from "react";
|
||||||
|
import {
|
||||||
|
getAvailableTransitionsByRole,
|
||||||
|
getOrderStatusComment,
|
||||||
|
getStatusTone,
|
||||||
|
} from "../../constants/deliveryWorkflow";
|
||||||
|
import { demoUsers } from "../../data/mockAppData";
|
||||||
|
import { getDeliveryCity, getDeliveryDay, getDeliveryHalfDay } from "../../services/driverDeliveries";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
|
const resolveUserName = (userId) => demoUsers.find((user) => user.id === userId)?.name || "Не назначен";
|
||||||
|
|
||||||
|
export const DriverDeliveryDetail = ({ order, onStatusChange }) => {
|
||||||
|
if (!order) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableTransitions = getAvailableTransitionsByRole({
|
||||||
|
status: order.status,
|
||||||
|
role: "driver",
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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 || "—"}</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>
|
||||||
|
<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">{getDeliveryCity(order)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Интервал доставки</p>
|
||||||
|
<p className="mt-1 font-medium">
|
||||||
|
{getDeliveryDay(order)} · {getDeliveryHalfDay(order)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Логист</p>
|
||||||
|
<p className="mt-1 font-medium">{resolveUserName(order.logisticianIds[0])}</p>
|
||||||
|
</div>
|
||||||
|
</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) => (
|
||||||
|
<div
|
||||||
|
key={item}
|
||||||
|
className="rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface)] px-4 py-3 text-sm"
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="space-y-4 p-6">
|
||||||
|
<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 || "Дополнительных комментариев нет."}
|
||||||
|
</div>
|
||||||
|
{order.comments?.length ? (
|
||||||
|
<div className="rounded-[20px] bg-[var(--color-surface)] p-4">
|
||||||
|
{order.comments.join(". ")}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<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 === "Проблема доставки" ? "ghost" : "secondary"}
|
||||||
|
onClick={() => onStatusChange(status)}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,331 @@
|
||||||
|
import React from "react";
|
||||||
|
import { getAvailableTransitionsByRole, getStatusTone } from "../../constants/deliveryWorkflow";
|
||||||
|
import {
|
||||||
|
buildDriverKanbanColumns,
|
||||||
|
filterDriverDeliveries,
|
||||||
|
getDeliveryCity,
|
||||||
|
getDeliveryHalfDay,
|
||||||
|
getDriverCities,
|
||||||
|
groupDriverDeliveriesByDate,
|
||||||
|
} 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<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>
|
||||||
|
</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.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>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{group.items.length} {group.items.length === 1 ? "доставка" : "доставки"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
{group.items.map((order) => {
|
||||||
|
const availableTransitions = getAvailableTransitionsByRole({
|
||||||
|
status: order.status,
|
||||||
|
role: "driver",
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
<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="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">
|
||||||
|
<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">
|
||||||
|
{availableTransitions.map((status) => (
|
||||||
|
<Button
|
||||||
|
key={status}
|
||||||
|
size="sm"
|
||||||
|
variant={status === "Проблема доставки" ? "ghost" : "secondary"}
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onStatusChange(order.id, status);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<h4 className="text-lg font-semibold">Доставки не найдены</h4>
|
||||||
|
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Попробуйте изменить дату, город или режим показа.
|
||||||
|
</p>
|
||||||
|
</Panel>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
|
import { Input } from "../UI/Input";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Select } from "../UI/Select";
|
||||||
|
|
||||||
|
export const BotControlPanel = ({
|
||||||
|
selectedOrder,
|
||||||
|
onSendBotMessage,
|
||||||
|
onReschedule,
|
||||||
|
canManageLogistics,
|
||||||
|
}) => {
|
||||||
|
const [channel, setChannel] = React.useState(selectedOrder?.customer.messenger || "Телеграм");
|
||||||
|
const [message, setMessage] = React.useState(
|
||||||
|
"Заказ готов к отгрузке. Выберите дату и половину дня для доставки.",
|
||||||
|
);
|
||||||
|
const [date, setDate] = React.useState("2026-03-11");
|
||||||
|
const [time, setTime] = React.useState("Первая половина дня");
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setChannel(selectedOrder?.customer.messenger || "Телеграм");
|
||||||
|
}, [selectedOrder]);
|
||||||
|
|
||||||
|
if (!selectedOrder) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Управление ботами</h3>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Отправка уведомлений и фиксация ответов клиента в истории заказа.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<Select value={channel} onChange={(event) => setChannel(event.target.value)}>
|
||||||
|
<option value="Телеграм">Телеграм</option>
|
||||||
|
<option value="ВКонтакте">ВКонтакте</option>
|
||||||
|
<option value="Макс">Макс</option>
|
||||||
|
<option value="СМС">СМС</option>
|
||||||
|
</Select>
|
||||||
|
<Input value={date} onChange={(event) => setDate(event.target.value)} type="date" />
|
||||||
|
<Select value={time} onChange={(event) => setTime(event.target.value)}>
|
||||||
|
<option value="Первая половина дня">Первая половина дня</option>
|
||||||
|
<option value="Вторая половина дня">Вторая половина дня</option>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input value={message} onChange={(event) => setMessage(event.target.value)} />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
onClick={() => onSendBotMessage({ sender: "bot", channel, text: message })}
|
||||||
|
disabled={!canManageLogistics}
|
||||||
|
>
|
||||||
|
Отправить клиенту
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => onReschedule({ date, time, logisticianId: selectedOrder.logisticianIds[0] })}
|
||||||
|
disabled={!canManageLogistics}
|
||||||
|
>
|
||||||
|
Перенести слот
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,382 @@
|
||||||
|
import React from "react";
|
||||||
|
import { ROLE_PERMISSIONS } from "../../constants/roles";
|
||||||
|
import {
|
||||||
|
getAvailableTransitionsByRole,
|
||||||
|
getDeliveryAgreementComment,
|
||||||
|
getOrderStatusComment,
|
||||||
|
getStatusTone,
|
||||||
|
} from "../../constants/deliveryWorkflow";
|
||||||
|
import { demoUsers } from "../../data/mockAppData";
|
||||||
|
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 resolveUserName = (userId) => demoUsers.find((user) => user.id === userId)?.name || "Не назначен";
|
||||||
|
const splitItem = (item) => {
|
||||||
|
const [name, quantity] = item.split("|").map((part) => part.trim());
|
||||||
|
return {
|
||||||
|
name: name || item,
|
||||||
|
quantity: quantity || "1",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrderDetailPanel = ({
|
||||||
|
order,
|
||||||
|
currentUser,
|
||||||
|
onStatusChange,
|
||||||
|
onClientMessage,
|
||||||
|
onInternalMessage,
|
||||||
|
onOrderNote,
|
||||||
|
}) => {
|
||||||
|
const [nextStatus, setNextStatus] = React.useState(order?.status || "Новый");
|
||||||
|
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 || "Новый");
|
||||||
|
setChatQuery("");
|
||||||
|
setActiveTab("overview");
|
||||||
|
setTeamReply("Новый комментарий для команды");
|
||||||
|
setNoteReply("Новая заметка по заказу");
|
||||||
|
}, [order]);
|
||||||
|
|
||||||
|
if (!order) {
|
||||||
|
return (
|
||||||
|
<Panel className="flex min-h-[460px] items-center justify-center">
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">Выберите заказ для просмотра деталей.</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = getAvailableTransitionsByRole({
|
||||||
|
status: order.status,
|
||||||
|
role: currentUser.role,
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
|
<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}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Менеджер</p>
|
||||||
|
<p className="mt-1 font-medium">{resolveUserName(order.managerId)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Логист</p>
|
||||||
|
<p className="mt-1 font-medium">{resolveUserName(order.logisticianIds[0])}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-[var(--color-text-muted)]">Водитель</p>
|
||||||
|
<p className="mt-1 font-medium">{resolveUserName(order.assignedDriverId)}</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>
|
||||||
|
|
||||||
|
<SegmentedTabs items={detailTabs} activeKey={activeTab} onChange={setActiveTab} />
|
||||||
|
|
||||||
|
{activeTab === "overview" ? (
|
||||||
|
<div className="grid gap-4 xl:grid-cols-[1.15fr_0.85fr]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<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="space-y-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,207 @@
|
||||||
|
import React from "react";
|
||||||
|
import { demoUsers } from "../../data/mockAppData";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
|
import { Input } from "../UI/Input";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Select } from "../UI/Select";
|
||||||
|
|
||||||
|
const managerOptions = demoUsers.filter((user) => user.role === "manager" || user.role === "admin");
|
||||||
|
const initialForm = {
|
||||||
|
orderNumber: "",
|
||||||
|
customerName: "",
|
||||||
|
customerPhone: "",
|
||||||
|
customerAddress: "",
|
||||||
|
messenger: "Телеграм",
|
||||||
|
managerId: managerOptions[0]?.id || "",
|
||||||
|
deliveryDate: "",
|
||||||
|
items: "",
|
||||||
|
comments: "",
|
||||||
|
tags: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrderEditorPanel = ({
|
||||||
|
currentUser,
|
||||||
|
selectedOrder,
|
||||||
|
onCreateOrder,
|
||||||
|
onSaveOrder,
|
||||||
|
createOnly = false,
|
||||||
|
onDone,
|
||||||
|
}) => {
|
||||||
|
const [form, setForm] = React.useState(initialForm);
|
||||||
|
const [isCreateMode, setIsCreateMode] = React.useState(createOnly);
|
||||||
|
const canManageOrders = currentUser.role === "manager" || currentUser.role === "admin";
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!createOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsCreateMode(true);
|
||||||
|
setForm({
|
||||||
|
...initialForm,
|
||||||
|
orderNumber: `CD-${Math.floor(Date.now() / 1000)}`,
|
||||||
|
managerId: currentUser.id,
|
||||||
|
});
|
||||||
|
}, [createOnly, currentUser.id]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!selectedOrder || isCreateMode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setForm({
|
||||||
|
orderNumber: selectedOrder.orderNumber,
|
||||||
|
customerName: selectedOrder.customer.name,
|
||||||
|
customerPhone: selectedOrder.customer.phone,
|
||||||
|
customerAddress: selectedOrder.customer.address,
|
||||||
|
messenger: selectedOrder.customer.messenger,
|
||||||
|
managerId: selectedOrder.managerId,
|
||||||
|
deliveryDate: selectedOrder.deliverySlots[0]?.date || "",
|
||||||
|
items: (selectedOrder.items || []).join("\n"),
|
||||||
|
comments: selectedOrder.comments.join(", "),
|
||||||
|
tags: selectedOrder.tags.join(", "),
|
||||||
|
});
|
||||||
|
}, [isCreateMode, selectedOrder]);
|
||||||
|
|
||||||
|
const updateField = (key, value) => {
|
||||||
|
setForm((current) => ({
|
||||||
|
...current,
|
||||||
|
[key]: value,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForCreate = () => {
|
||||||
|
setIsCreateMode(true);
|
||||||
|
setForm({
|
||||||
|
...initialForm,
|
||||||
|
orderNumber: `CD-${Math.floor(Date.now() / 1000)}`,
|
||||||
|
managerId: currentUser.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetForSelected = () => {
|
||||||
|
setIsCreateMode(false);
|
||||||
|
if (!selectedOrder) {
|
||||||
|
setForm(initialForm);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!form.orderNumber || !form.customerName || !form.customerPhone || !form.customerAddress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isCreateMode) {
|
||||||
|
onCreateOrder({ payload: form, actorName: currentUser.name });
|
||||||
|
if (createOnly) {
|
||||||
|
onDone?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsCreateMode(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedOrder) {
|
||||||
|
onSaveOrder({
|
||||||
|
orderId: selectedOrder.id,
|
||||||
|
payload: form,
|
||||||
|
actorName: currentUser.name,
|
||||||
|
});
|
||||||
|
onDone?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="space-y-4 p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Управление заказом</h3>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Создание и редактирование заказа с полями клиента, канала связи и даты доставки.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!createOnly ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="secondary" onClick={resetForCreate} disabled={!canManageOrders}>
|
||||||
|
Новый заказ
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={resetForSelected}>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Номер заказа"
|
||||||
|
value={form.orderNumber}
|
||||||
|
onChange={(event) => updateField("orderNumber", event.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={form.managerId}
|
||||||
|
onChange={(event) => updateField("managerId", event.target.value)}
|
||||||
|
>
|
||||||
|
{managerOptions.map((user) => (
|
||||||
|
<option key={user.id} value={user.id}>
|
||||||
|
{user.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
placeholder="Имя клиента"
|
||||||
|
value={form.customerName}
|
||||||
|
onChange={(event) => updateField("customerName", event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Телефон"
|
||||||
|
value={form.customerPhone}
|
||||||
|
onChange={(event) => updateField("customerPhone", event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Адрес доставки"
|
||||||
|
value={form.customerAddress}
|
||||||
|
onChange={(event) => updateField("customerAddress", event.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={form.messenger}
|
||||||
|
onChange={(event) => updateField("messenger", event.target.value)}
|
||||||
|
>
|
||||||
|
<option value="Телеграм">Телеграм</option>
|
||||||
|
<option value="ВКонтакте">ВКонтакте</option>
|
||||||
|
<option value="Макс">Макс</option>
|
||||||
|
<option value="СМС">СМС</option>
|
||||||
|
<option value="Эл. почта">Эл. почта</option>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
type="date"
|
||||||
|
value={form.deliveryDate}
|
||||||
|
onChange={(event) => updateField("deliveryDate", event.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
placeholder="Теги через запятую"
|
||||||
|
value={form.tags}
|
||||||
|
onChange={(event) => updateField("tags", event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className="min-h-28 w-full rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||||
|
placeholder="Состав заказа, каждая строка: название | количество"
|
||||||
|
value={form.items}
|
||||||
|
onChange={(event) => updateField("items", event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
className="min-h-28 w-full rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text)] focus:border-[var(--color-accent)] focus:outline-none"
|
||||||
|
placeholder="Комментарии через запятую"
|
||||||
|
value={form.comments}
|
||||||
|
onChange={(event) => updateField("comments", event.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button className="w-full" onClick={handleSubmit} disabled={!canManageOrders}>
|
||||||
|
{isCreateMode ? "Создать заказ" : "Сохранить изменения"}
|
||||||
|
</Button>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import React from "react";
|
||||||
|
import { ORDER_STATUSES } from "../../constants/orderStatuses";
|
||||||
|
import { demoUsers } from "../../data/mockAppData";
|
||||||
|
import { Input } from "../UI/Input";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { Select } from "../UI/Select";
|
||||||
|
|
||||||
|
const logisticians = demoUsers.filter((user) => user.role === "logistician");
|
||||||
|
const managers = demoUsers.filter((user) => user.role === "manager");
|
||||||
|
const messengers = ["Телеграм", "ВКонтакте", "Макс", "СМС", "Эл. почта"];
|
||||||
|
|
||||||
|
export const OrderFilters = ({ filters, setFilters }) => {
|
||||||
|
return (
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
||||||
|
<Input
|
||||||
|
placeholder="Поиск по заказу, клиенту, тегу"
|
||||||
|
value={filters.query}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters((current) => ({ ...current, query: event.target.value }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.status}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters((current) => ({ ...current, status: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="all">Все статусы</option>
|
||||||
|
{ORDER_STATUSES.map((status) => (
|
||||||
|
<option key={status} value={status}>
|
||||||
|
{status}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.managerId}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters((current) => ({ ...current, managerId: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="all">Все менеджеры</option>
|
||||||
|
{managers.map((manager) => (
|
||||||
|
<option key={manager.id} value={manager.id}>
|
||||||
|
{manager.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.logisticianId}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters((current) => ({ ...current, logisticianId: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="all">Все логисты</option>
|
||||||
|
{logisticians.map((logistician) => (
|
||||||
|
<option key={logistician.id} value={logistician.id}>
|
||||||
|
{logistician.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={filters.messenger}
|
||||||
|
onChange={(event) =>
|
||||||
|
setFilters((current) => ({ ...current, messenger: event.target.value }))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="all">Все каналы</option>
|
||||||
|
{messengers.map((messenger) => (
|
||||||
|
<option key={messenger} value={messenger}>
|
||||||
|
{messenger}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "../UI/Button";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
|
const WEEK_DAYS = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
|
||||||
|
|
||||||
|
const startOfMonth = (date) => new Date(date.getFullYear(), date.getMonth(), 1);
|
||||||
|
const endOfMonth = (date) => new Date(date.getFullYear(), date.getMonth() + 1, 0);
|
||||||
|
const addMonths = (date, amount) => new Date(date.getFullYear(), date.getMonth() + amount, 1);
|
||||||
|
const formatDayKey = (date) => date.toISOString().slice(0, 10);
|
||||||
|
const labelMonth = (date) =>
|
||||||
|
date.toLocaleDateString("ru-RU", { month: "long", year: "numeric" });
|
||||||
|
|
||||||
|
const resolveOrderDay = (order) => order.deliverySlots[0]?.date || order.scheduledDelivery.slice(0, 10);
|
||||||
|
|
||||||
|
const buildCalendarDays = (currentMonth) => {
|
||||||
|
const firstDay = startOfMonth(currentMonth);
|
||||||
|
const lastDay = endOfMonth(currentMonth);
|
||||||
|
const firstWeekDay = (firstDay.getDay() + 6) % 7;
|
||||||
|
const totalDays = lastDay.getDate();
|
||||||
|
const cells = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < firstWeekDay; index += 1) {
|
||||||
|
cells.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let day = 1; day <= totalDays; day += 1) {
|
||||||
|
cells.push(new Date(currentMonth.getFullYear(), currentMonth.getMonth(), day));
|
||||||
|
}
|
||||||
|
|
||||||
|
while (cells.length % 7 !== 0) {
|
||||||
|
cells.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrdersCalendarView = ({ orders, onOpenOrder }) => {
|
||||||
|
const initialMonth = React.useMemo(() => {
|
||||||
|
if (!orders.length) {
|
||||||
|
return startOfMonth(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstOrderDate = new Date(`${resolveOrderDay(orders[0])}T00:00:00`);
|
||||||
|
return startOfMonth(firstOrderDate);
|
||||||
|
}, [orders]);
|
||||||
|
|
||||||
|
const [currentMonth, setCurrentMonth] = React.useState(initialMonth);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setCurrentMonth(initialMonth);
|
||||||
|
}, [initialMonth]);
|
||||||
|
|
||||||
|
const calendarDays = React.useMemo(() => buildCalendarDays(currentMonth), [currentMonth]);
|
||||||
|
const ordersByDay = React.useMemo(
|
||||||
|
() =>
|
||||||
|
orders.reduce((accumulator, order) => {
|
||||||
|
const key = resolveOrderDay(order);
|
||||||
|
accumulator[key] = accumulator[key] || [];
|
||||||
|
accumulator[key].push(order);
|
||||||
|
return accumulator;
|
||||||
|
}, {}),
|
||||||
|
[orders],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel className="space-y-5 p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Календарь доставок</h3>
|
||||||
|
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Месячный вид по датам доставки. Клик по заказу открывает карточку.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setCurrentMonth(addMonths(currentMonth, -1))}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
<div className="min-w-[180px] text-center text-sm font-medium capitalize">
|
||||||
|
{labelMonth(currentMonth)}
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}>
|
||||||
|
Вперёд
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-7 gap-3 text-xs uppercase tracking-[0.12em] text-[var(--color-text-muted)]">
|
||||||
|
{WEEK_DAYS.map((day) => (
|
||||||
|
<div key={day} className="px-2">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-7">
|
||||||
|
{calendarDays.map((day, index) => {
|
||||||
|
if (!day) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`empty-${index}`}
|
||||||
|
className="hidden min-h-[132px] rounded-[24px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface)]/50 md:block"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = formatDayKey(day);
|
||||||
|
const dayOrders = ordersByDay[key] || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={key}
|
||||||
|
className="min-h-[132px] rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3"
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
|
<div className="text-sm font-semibold">{day.getDate()}</div>
|
||||||
|
<div className="text-xs text-[var(--color-text-muted)]">{dayOrders.length || ""}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{dayOrders.slice(0, 2).map((order) => (
|
||||||
|
<button
|
||||||
|
key={order.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenOrder(order.id)}
|
||||||
|
className="w-full rounded-[16px] bg-[var(--color-surface)] px-3 py-2 text-left text-sm hover:bg-[var(--color-accent-soft)]"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{order.orderNumber}</div>
|
||||||
|
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{order.customer.name}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{dayOrders.length > 2 ? (
|
||||||
|
<div className="px-1 text-xs text-[var(--color-text-muted)]">
|
||||||
|
Ещё {dayOrders.length - 2}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
import React from "react";
|
||||||
|
import { getStatusTone } from "../../constants/deliveryWorkflow";
|
||||||
|
import { demoUsers } from "../../data/mockAppData";
|
||||||
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
|
import { Badge } from "../UI/Badge";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
|
const resolveUserName = (userId) => demoUsers.find((user) => user.id === userId)?.name || "Не назначен";
|
||||||
|
const buildOrderSummary = (order) => {
|
||||||
|
const leadItem = order.items?.[0] || "Состав не указан";
|
||||||
|
const leadComment = order.orderNotes?.[0]?.text || order.comments?.[0] || "Без уточнений";
|
||||||
|
return `${leadItem}. ${leadComment}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OrdersTable = ({ orders, selectedOrderId, onOpenOrder }) => {
|
||||||
|
return (
|
||||||
|
<Panel className="overflow-hidden p-0">
|
||||||
|
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">Реестр заказов</h2>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Клик по строке открывает карточку в модальном окне.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="neutral">{orders.length}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full border-collapse">
|
||||||
|
<thead className="bg-[var(--color-surface-strong)] text-left text-xs uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
|
||||||
|
<tr>
|
||||||
|
<th className="px-5 py-4 font-medium">Заказ</th>
|
||||||
|
<th className="px-5 py-4 font-medium">Клиент</th>
|
||||||
|
<th className="px-5 py-4 font-medium">Кратко</th>
|
||||||
|
<th className="px-5 py-4 font-medium">Статус</th>
|
||||||
|
<th className="px-5 py-4 font-medium">Менеджер</th>
|
||||||
|
<th className="px-5 py-4 font-medium">Обновлён</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{orders.map((order) => (
|
||||||
|
<tr
|
||||||
|
key={order.id}
|
||||||
|
className={[
|
||||||
|
"cursor-pointer border-t border-[var(--color-border)] transition hover:bg-[var(--color-accent-soft)]",
|
||||||
|
selectedOrderId === order.id ? "bg-[var(--color-accent-soft)]" : "",
|
||||||
|
].join(" ")}
|
||||||
|
onClick={() => onOpenOrder(order.id)}
|
||||||
|
>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="font-medium">{order.orderNumber}</div>
|
||||||
|
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{order.customer.messenger}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-sm">
|
||||||
|
<div>{order.customer.name}</div>
|
||||||
|
<div className="mt-1 text-[var(--color-text-muted)]">{order.customer.phone}</div>
|
||||||
|
</td>
|
||||||
|
<td className="max-w-[340px] px-5 py-4 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{buildOrderSummary(order)}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-sm">{resolveUserName(order.managerId)}</td>
|
||||||
|
<td className="px-5 py-4 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{formatDateTime(order.updatedAt)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,167 @@
|
||||||
|
export const ORDER_STATUS_META = {
|
||||||
|
"Новый": {
|
||||||
|
comment: "Заказ создан и ожидает проверки менеджером.",
|
||||||
|
ownerRole: "manager",
|
||||||
|
tone: "neutral",
|
||||||
|
},
|
||||||
|
"Требует уточнения": {
|
||||||
|
comment: "В заказе не хватает данных, их должен уточнить менеджер.",
|
||||||
|
ownerRole: "manager",
|
||||||
|
tone: "warning",
|
||||||
|
},
|
||||||
|
"Подтверждён менеджером": {
|
||||||
|
comment: "Менеджер проверил заказ и передал его дальше в работу.",
|
||||||
|
ownerRole: "manager",
|
||||||
|
tone: "accent",
|
||||||
|
},
|
||||||
|
"В очереди производства": {
|
||||||
|
comment: "Заказ передан на производство и ожидает запуска.",
|
||||||
|
ownerRole: "production_lead",
|
||||||
|
tone: "neutral",
|
||||||
|
},
|
||||||
|
"В производстве": {
|
||||||
|
comment: "Заказ находится в изготовлении.",
|
||||||
|
ownerRole: "production_lead",
|
||||||
|
tone: "accent",
|
||||||
|
},
|
||||||
|
"Готов к отгрузке": {
|
||||||
|
comment: "Производство завершено, можно запускать согласование доставки.",
|
||||||
|
ownerRole: "production_lead",
|
||||||
|
tone: "accent",
|
||||||
|
},
|
||||||
|
"Ожидает согласования доставки": {
|
||||||
|
comment: "Клиенту отправлено предложение выбрать дату и половину дня доставки.",
|
||||||
|
ownerRole: "logistician",
|
||||||
|
tone: "warning",
|
||||||
|
},
|
||||||
|
"Доставка согласована": {
|
||||||
|
comment: "Клиент подтвердил доставку, логист может назначать рейс.",
|
||||||
|
ownerRole: "logistician",
|
||||||
|
tone: "accent",
|
||||||
|
},
|
||||||
|
"Назначен водитель": {
|
||||||
|
comment: "Логист распределил заказ на конкретного водителя.",
|
||||||
|
ownerRole: "logistician",
|
||||||
|
tone: "accent",
|
||||||
|
},
|
||||||
|
Загружен: {
|
||||||
|
comment: "Заказ физически загружен в транспорт.",
|
||||||
|
ownerRole: "driver",
|
||||||
|
tone: "neutral",
|
||||||
|
},
|
||||||
|
"В пути": {
|
||||||
|
comment: "Водитель выехал и выполняет доставку.",
|
||||||
|
ownerRole: "driver",
|
||||||
|
tone: "accent",
|
||||||
|
},
|
||||||
|
Доставлен: {
|
||||||
|
comment: "Заказ успешно передан клиенту.",
|
||||||
|
ownerRole: "driver",
|
||||||
|
tone: "accent",
|
||||||
|
},
|
||||||
|
Закрыт: {
|
||||||
|
comment: "Цикл заказа завершён и больше не требует действий.",
|
||||||
|
ownerRole: "logistician",
|
||||||
|
tone: "neutral",
|
||||||
|
},
|
||||||
|
Отменён: {
|
||||||
|
comment: "Заказ отменён и выведен из процесса.",
|
||||||
|
ownerRole: "manager",
|
||||||
|
tone: "danger",
|
||||||
|
},
|
||||||
|
"Проблема доставки": {
|
||||||
|
comment: "На этапе доставки возникла проблема и нужен ручной разбор.",
|
||||||
|
ownerRole: "logistician",
|
||||||
|
tone: "danger",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ORDER_STATUSES = Object.keys(ORDER_STATUS_META);
|
||||||
|
|
||||||
|
export const DELIVERY_AGREEMENT_STATUS_META = {
|
||||||
|
"Не начато": {
|
||||||
|
comment: "Процесс согласования доставки ещё не запускался.",
|
||||||
|
},
|
||||||
|
"Отправлено клиенту": {
|
||||||
|
comment: "Клиенту отправлено предложение согласовать доставку.",
|
||||||
|
},
|
||||||
|
"Ожидание ответа": {
|
||||||
|
comment: "Система ждёт ответ клиента по предложенному слоту.",
|
||||||
|
},
|
||||||
|
"Подтверждено клиентом": {
|
||||||
|
comment: "Клиент подтвердил дату и половину дня доставки.",
|
||||||
|
},
|
||||||
|
"Перенос запрошен": {
|
||||||
|
comment: "Клиент просит изменить дату или временной интервал.",
|
||||||
|
},
|
||||||
|
"Нет ответа": {
|
||||||
|
comment: "Клиент не ответил в пределах SLA.",
|
||||||
|
},
|
||||||
|
"Ошибка отправки": {
|
||||||
|
comment: "Сообщение не удалось отправить, требуется повторная попытка.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DELIVERY_AGREEMENT_STATUSES = Object.keys(DELIVERY_AGREEMENT_STATUS_META);
|
||||||
|
|
||||||
|
export const ORDER_STATUS_TRANSITIONS = {
|
||||||
|
"Новый": ["Требует уточнения", "Подтверждён менеджером", "Отменён"],
|
||||||
|
"Требует уточнения": ["Новый", "Подтверждён менеджером", "Отменён"],
|
||||||
|
"Подтверждён менеджером": ["В очереди производства", "Требует уточнения", "Отменён"],
|
||||||
|
"В очереди производства": ["В производстве", "Требует уточнения", "Отменён"],
|
||||||
|
"В производстве": ["Готов к отгрузке", "Требует уточнения", "Отменён"],
|
||||||
|
"Готов к отгрузке": ["Ожидает согласования доставки", "Проблема доставки", "Отменён"],
|
||||||
|
"Ожидает согласования доставки": ["Доставка согласована", "Проблема доставки", "Отменён"],
|
||||||
|
"Доставка согласована": ["Назначен водитель", "Ожидает согласования доставки", "Проблема доставки"],
|
||||||
|
"Назначен водитель": ["Загружен", "Проблема доставки"],
|
||||||
|
Загружен: ["В пути", "Проблема доставки"],
|
||||||
|
"В пути": ["Доставлен", "Проблема доставки"],
|
||||||
|
Доставлен: ["Закрыт"],
|
||||||
|
"Проблема доставки": ["Ожидает согласования доставки", "Назначен водитель", "Отменён", "Закрыт"],
|
||||||
|
Закрыт: [],
|
||||||
|
Отменён: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROLE_TRANSITION_TARGETS = {
|
||||||
|
manager: ["Новый", "Требует уточнения", "Подтверждён менеджером", "В очереди производства", "Отменён"],
|
||||||
|
production_lead: ["В очереди производства", "В производстве", "Готов к отгрузке", "Требует уточнения", "Отменён"],
|
||||||
|
logistician: [
|
||||||
|
"Ожидает согласования доставки",
|
||||||
|
"Доставка согласована",
|
||||||
|
"Назначен водитель",
|
||||||
|
"Проблема доставки",
|
||||||
|
"Закрыт",
|
||||||
|
"Отменён",
|
||||||
|
],
|
||||||
|
driver: ["Загружен", "В пути", "Доставлен", "Проблема доставки"],
|
||||||
|
admin: ORDER_STATUSES,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PRODUCTION_STATUSES = [
|
||||||
|
"В очереди производства",
|
||||||
|
"В производстве",
|
||||||
|
"Готов к отгрузке",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const LOGISTICS_STATUSES = [
|
||||||
|
"Готов к отгрузке",
|
||||||
|
"Ожидает согласования доставки",
|
||||||
|
"Доставка согласована",
|
||||||
|
"Назначен водитель",
|
||||||
|
"Проблема доставки",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DRIVER_STATUSES = ["Назначен водитель", "Загружен", "В пути", "Доставлен"];
|
||||||
|
|
||||||
|
export const getOrderStatusComment = (status) => ORDER_STATUS_META[status]?.comment || "Комментарий не задан.";
|
||||||
|
|
||||||
|
export const getDeliveryAgreementComment = (status) =>
|
||||||
|
DELIVERY_AGREEMENT_STATUS_META[status]?.comment || "Комментарий не задан.";
|
||||||
|
|
||||||
|
export const getStatusTone = (status) => ORDER_STATUS_META[status]?.tone || "neutral";
|
||||||
|
|
||||||
|
export const getAvailableTransitionsByRole = ({ status, role }) => {
|
||||||
|
const nextStatuses = ORDER_STATUS_TRANSITIONS[status] || [];
|
||||||
|
const allowedTargets = ROLE_TRANSITION_TARGETS[role] || [];
|
||||||
|
return nextStatuses.filter((nextStatus) => allowedTargets.includes(nextStatus));
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { ORDER_STATUSES } from "./deliveryWorkflow";
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
export const ROLE_LABELS = {
|
||||||
|
manager: "Менеджер",
|
||||||
|
production_lead: "Начальник производства",
|
||||||
|
logistician: "Логист",
|
||||||
|
driver: "Водитель",
|
||||||
|
admin: "Администратор",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ROLE_PERMISSIONS = {
|
||||||
|
manager: [
|
||||||
|
"Создание и редактирование заказов",
|
||||||
|
"Поиск и фильтрация по заказам",
|
||||||
|
"Комментарии и контроль подтверждения",
|
||||||
|
],
|
||||||
|
production_lead: [
|
||||||
|
"Очередь производства",
|
||||||
|
"Изменение статусов производства",
|
||||||
|
"Контроль готовности к доставке",
|
||||||
|
],
|
||||||
|
logistician: [
|
||||||
|
"Согласование доставки с клиентом",
|
||||||
|
"Назначение водителя и рейса",
|
||||||
|
"Разбор проблемных доставок",
|
||||||
|
],
|
||||||
|
driver: [
|
||||||
|
"Просмотр назначенных доставок",
|
||||||
|
"Подтверждение загрузки и выезда",
|
||||||
|
"Фиксация результата доставки",
|
||||||
|
],
|
||||||
|
admin: [
|
||||||
|
"Полный доступ к заказам",
|
||||||
|
"Управление пользователями и ролями",
|
||||||
|
"Логи, ошибки и история действий",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,168 @@
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
import { demoUsers } from "../data/mockAppData";
|
||||||
|
import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
||||||
|
import { safeSupabaseCall } from "../services/safeSupabaseCall";
|
||||||
|
|
||||||
|
const AuthContext = createContext(null);
|
||||||
|
const STORAGE_KEY = "construction-auth-demo-user";
|
||||||
|
export const DEMO_LOGIN_EMAIL = "demo@local";
|
||||||
|
|
||||||
|
export const resolveDemoUser = (email, roleHint) => {
|
||||||
|
return (
|
||||||
|
demoUsers.find((user) => user.role === roleHint) ||
|
||||||
|
demoUsers.find((user) => user.email === email) ||
|
||||||
|
demoUsers[0]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveLoginEmail = (isDemoMode, email) =>
|
||||||
|
isDemoMode ? DEMO_LOGIN_EMAIL : email;
|
||||||
|
|
||||||
|
export const AuthProvider = ({ children }) => {
|
||||||
|
const [user, setUser] = useState(() => {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return stored ? JSON.parse(stored) : null;
|
||||||
|
});
|
||||||
|
const [pendingEmail, setPendingEmail] = useState("");
|
||||||
|
const [isOtpSent, setIsOtpSent] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasSupabaseConfig || !supabase) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { subscription },
|
||||||
|
} = supabase.auth.onAuthStateChange(async (_event, session) => {
|
||||||
|
if (!session?.user) {
|
||||||
|
setUser(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await safeSupabaseCall(async () => {
|
||||||
|
const { data: profile, error } = await supabase
|
||||||
|
.from("users")
|
||||||
|
.select("id, email, name, last_login, role_info:roles(name)")
|
||||||
|
.eq("id", session.user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return profile;
|
||||||
|
}, "Ошибка загрузки профиля");
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
setUser({
|
||||||
|
id: data.id,
|
||||||
|
email: data.email,
|
||||||
|
name: data.name,
|
||||||
|
role: data.role_info?.name || "manager",
|
||||||
|
lastLogin: data.last_login,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => subscription.unsubscribe();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && !hasSupabaseConfig) {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
|
||||||
|
}
|
||||||
|
if (!user && !hasSupabaseConfig) {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
|
const requestOtp = async ({ email, roleHint }) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setPendingEmail(email);
|
||||||
|
try {
|
||||||
|
if (hasSupabaseConfig && supabase) {
|
||||||
|
const { error } = await supabase.auth.signInWithOtp({
|
||||||
|
email,
|
||||||
|
options: {
|
||||||
|
shouldCreateUser: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
localStorage.setItem("construction-auth-role-hint", roleHint || "manager");
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOtpSent(true);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error };
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verifyOtp = async ({ email, otp }) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (hasSupabaseConfig && supabase) {
|
||||||
|
const { data, error } = await supabase.auth.verifyOtp({
|
||||||
|
email,
|
||||||
|
token: otp,
|
||||||
|
type: "email",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: Boolean(data.session) };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (otp !== "000000") {
|
||||||
|
throw new Error("Для demo-режима используйте код 000000");
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleHint = localStorage.getItem("construction-auth-role-hint");
|
||||||
|
const demoUser = resolveDemoUser(email, roleHint);
|
||||||
|
setUser(demoUser);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error };
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const signOut = async () => {
|
||||||
|
if (hasSupabaseConfig && supabase) {
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
}
|
||||||
|
setUser(null);
|
||||||
|
setPendingEmail("");
|
||||||
|
setIsOtpSent(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
user,
|
||||||
|
pendingEmail,
|
||||||
|
isOtpSent,
|
||||||
|
isLoading,
|
||||||
|
isDemoMode: !hasSupabaseConfig,
|
||||||
|
requestOtp,
|
||||||
|
verifyOtp,
|
||||||
|
signOut,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAuth = () => {
|
||||||
|
const context = useContext(AuthContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { DEMO_LOGIN_EMAIL, resolveDemoUser, resolveLoginEmail } from "./AuthContext";
|
||||||
|
|
||||||
|
describe("resolveDemoUser", () => {
|
||||||
|
it("prioritizes the selected demo role over a matching email account", () => {
|
||||||
|
const user = resolveDemoUser("manager@demo.local", "driver");
|
||||||
|
|
||||||
|
expect(user.role).toBe("driver");
|
||||||
|
expect(user.email).toBe("driver@demo.local");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveLoginEmail", () => {
|
||||||
|
it("forces a single shared email in demo mode", () => {
|
||||||
|
expect(resolveLoginEmail(true, "manager@demo.local")).toBe(DEMO_LOGIN_EMAIL);
|
||||||
|
expect(resolveLoginEmail(true, "driver@demo.local")).toBe(DEMO_LOGIN_EMAIL);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the entered email outside demo mode", () => {
|
||||||
|
expect(resolveLoginEmail(false, "user@company.ru")).toBe("user@company.ru");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
const ThemeContext = createContext(null);
|
||||||
|
|
||||||
|
const STORAGE_KEY = "construction-theme";
|
||||||
|
|
||||||
|
export const ThemeProvider = ({ children }) => {
|
||||||
|
const [theme, setTheme] = useState(() => {
|
||||||
|
return localStorage.getItem(STORAGE_KEY) || "light";
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.setAttribute("data-theme", theme);
|
||||||
|
localStorage.setItem(STORAGE_KEY, theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
theme,
|
||||||
|
toggleTheme: () => setTheme((current) => (current === "light" ? "dark" : "light")),
|
||||||
|
};
|
||||||
|
|
||||||
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useTheme = () => {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useTheme must be used within ThemeProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,650 @@
|
||||||
|
export const demoUsers = [
|
||||||
|
{
|
||||||
|
id: "u-manager",
|
||||||
|
email: "manager@demo.local",
|
||||||
|
name: "Анна Мельник",
|
||||||
|
phone: "+7 978 300-10-01",
|
||||||
|
role: "manager",
|
||||||
|
lastLogin: "2026-03-12T08:15:00Z",
|
||||||
|
botBindings: {
|
||||||
|
telegram: "@anna_manager",
|
||||||
|
vk: "id1045001",
|
||||||
|
messengerMax: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-production",
|
||||||
|
email: "production@demo.local",
|
||||||
|
name: "Илья Корнеев",
|
||||||
|
phone: "+7 978 300-10-02",
|
||||||
|
role: "production_lead",
|
||||||
|
lastLogin: "2026-03-12T09:05:00Z",
|
||||||
|
botBindings: {
|
||||||
|
telegram: "@production_lead",
|
||||||
|
vk: null,
|
||||||
|
messengerMax: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-logistics",
|
||||||
|
email: "logistics@demo.local",
|
||||||
|
name: "Ольга Синицына",
|
||||||
|
phone: "+7 978 300-10-03",
|
||||||
|
role: "logistician",
|
||||||
|
lastLogin: "2026-03-12T10:22:00Z",
|
||||||
|
botBindings: {
|
||||||
|
telegram: "@olga_route",
|
||||||
|
vk: "id1045002",
|
||||||
|
messengerMax: "olga.max",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-logistics-2",
|
||||||
|
email: "route@demo.local",
|
||||||
|
name: "Павел Миронов",
|
||||||
|
phone: "+7 978 300-10-04",
|
||||||
|
role: "logistician",
|
||||||
|
lastLogin: "2026-03-12T08:47:00Z",
|
||||||
|
botBindings: {
|
||||||
|
telegram: "@pavel_route",
|
||||||
|
vk: null,
|
||||||
|
messengerMax: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-driver",
|
||||||
|
email: "driver@demo.local",
|
||||||
|
name: "Артём Громов",
|
||||||
|
phone: "+7 978 300-10-06",
|
||||||
|
role: "driver",
|
||||||
|
lastLogin: "2026-03-12T06:55:00Z",
|
||||||
|
botBindings: {
|
||||||
|
telegram: null,
|
||||||
|
vk: null,
|
||||||
|
messengerMax: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "u-admin",
|
||||||
|
email: "admin@demo.local",
|
||||||
|
name: "Максим Белов",
|
||||||
|
phone: "+7 978 300-10-05",
|
||||||
|
role: "admin",
|
||||||
|
lastLogin: "2026-03-12T11:41:00Z",
|
||||||
|
botBindings: {
|
||||||
|
telegram: "@max_admin",
|
||||||
|
vk: "id1045003",
|
||||||
|
messengerMax: "max.admin",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const demoOrders = [
|
||||||
|
{
|
||||||
|
id: "o-1001",
|
||||||
|
orderNumber: "CD-240031",
|
||||||
|
customer: {
|
||||||
|
name: "Мария Волкова",
|
||||||
|
phone: "+7 978 000-12-31",
|
||||||
|
messenger: "Телеграм",
|
||||||
|
address: "Симферополь, ул. Тургенева, 18",
|
||||||
|
},
|
||||||
|
status: "Ожидает согласования доставки",
|
||||||
|
deliveryAgreementStatus: "Ожидание ответа",
|
||||||
|
managerId: "u-manager",
|
||||||
|
logisticianIds: ["u-logistics"],
|
||||||
|
assignedDriverId: null,
|
||||||
|
createdAt: "2026-03-11T06:45:00Z",
|
||||||
|
updatedAt: "2026-03-12T09:40:00Z",
|
||||||
|
scheduledDelivery: "2026-03-14T08:00:00Z",
|
||||||
|
items: [
|
||||||
|
"Кухонный гарнитур | 1 комплект",
|
||||||
|
"Фурнитура Blum | 12 шт",
|
||||||
|
"Монтажный комплект | 1 набор",
|
||||||
|
],
|
||||||
|
tags: ["срочно", "кухня"],
|
||||||
|
comments: ["Клиент просит подтверждение за 2 часа до доставки"],
|
||||||
|
orderNotes: [
|
||||||
|
{
|
||||||
|
id: "note-1",
|
||||||
|
authorName: "Анна Мельник",
|
||||||
|
text: "Проверить доступность подъезда для разгрузки.",
|
||||||
|
createdAt: "2026-03-11T08:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: "h-1",
|
||||||
|
action: "Создан заказ",
|
||||||
|
oldStatus: null,
|
||||||
|
newStatus: "Новый",
|
||||||
|
userName: "Анна Мельник",
|
||||||
|
at: "2026-03-11T06:45:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "h-2",
|
||||||
|
action: "Подтверждение менеджером",
|
||||||
|
oldStatus: "Новый",
|
||||||
|
newStatus: "Подтверждён менеджером",
|
||||||
|
userName: "Анна Мельник",
|
||||||
|
at: "2026-03-11T07:10:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "h-3",
|
||||||
|
action: "Передан в производство",
|
||||||
|
oldStatus: "Подтверждён менеджером",
|
||||||
|
newStatus: "В очереди производства",
|
||||||
|
userName: "Анна Мельник",
|
||||||
|
at: "2026-03-11T07:45:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "h-4",
|
||||||
|
action: "Заказ готов к отгрузке",
|
||||||
|
oldStatus: "В производстве",
|
||||||
|
newStatus: "Готов к отгрузке",
|
||||||
|
userName: "Илья Корнеев",
|
||||||
|
at: "2026-03-12T08:20:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "h-5",
|
||||||
|
action: "Запущено согласование доставки",
|
||||||
|
oldStatus: "Готов к отгрузке",
|
||||||
|
newStatus: "Ожидает согласования доставки",
|
||||||
|
userName: "Система",
|
||||||
|
at: "2026-03-12T09:40:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chatMessages: [
|
||||||
|
{
|
||||||
|
id: "c-1",
|
||||||
|
sender: "bot",
|
||||||
|
channel: "Телеграм",
|
||||||
|
text: "Заказ CD-240031 готов. Выберите дату и половину дня доставки.",
|
||||||
|
sentAt: "2026-03-12T09:42:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "c-2",
|
||||||
|
sender: "client",
|
||||||
|
channel: "Телеграм",
|
||||||
|
text: "Подтвержу позже, вернусь после 16:00.",
|
||||||
|
sentAt: "2026-03-12T10:05:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
internalMessages: [
|
||||||
|
{
|
||||||
|
id: "ic-1",
|
||||||
|
senderId: "u-manager",
|
||||||
|
senderName: "Анна Мельник",
|
||||||
|
text: "Клиент просил предварительный звонок перед доставкой.",
|
||||||
|
sentAt: "2026-03-12T09:50:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deliverySlots: [
|
||||||
|
{
|
||||||
|
id: "ds-1",
|
||||||
|
date: "2026-03-14",
|
||||||
|
time: "Первая половина дня",
|
||||||
|
logisticianId: "u-logistics",
|
||||||
|
status: "Ожидает подтверждения",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exception: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "o-1002",
|
||||||
|
orderNumber: "CD-240032",
|
||||||
|
customer: {
|
||||||
|
name: "Александр Савин",
|
||||||
|
phone: "+7 978 000-12-32",
|
||||||
|
messenger: "ВКонтакте",
|
||||||
|
address: "Ялта, ул. Чехова, 9",
|
||||||
|
},
|
||||||
|
status: "Готов к отгрузке",
|
||||||
|
deliveryAgreementStatus: "Не начато",
|
||||||
|
managerId: "u-manager",
|
||||||
|
logisticianIds: ["u-logistics"],
|
||||||
|
assignedDriverId: null,
|
||||||
|
createdAt: "2026-03-10T11:20:00Z",
|
||||||
|
updatedAt: "2026-03-12T08:20:00Z",
|
||||||
|
scheduledDelivery: "2026-03-15T13:00:00Z",
|
||||||
|
items: ["Стеклопакет 2400x1800 | 2 шт", "Комплект крепежа | 1 набор"],
|
||||||
|
tags: ["стеклопакет"],
|
||||||
|
comments: ["Нужен созвон перед отгрузкой"],
|
||||||
|
orderNotes: [
|
||||||
|
{
|
||||||
|
id: "note-2",
|
||||||
|
authorName: "Илья Корнеев",
|
||||||
|
text: "Производство завершено, передаём логистике после фотофиксации.",
|
||||||
|
createdAt: "2026-03-12T08:35:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: "h-6",
|
||||||
|
action: "Запущено производство",
|
||||||
|
oldStatus: "В очереди производства",
|
||||||
|
newStatus: "В производстве",
|
||||||
|
userName: "Илья Корнеев",
|
||||||
|
at: "2026-03-11T12:20:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "h-7",
|
||||||
|
action: "Заказ готов к отгрузке",
|
||||||
|
oldStatus: "В производстве",
|
||||||
|
newStatus: "Готов к отгрузке",
|
||||||
|
userName: "Илья Корнеев",
|
||||||
|
at: "2026-03-12T08:20:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chatMessages: [],
|
||||||
|
internalMessages: [
|
||||||
|
{
|
||||||
|
id: "ic-2",
|
||||||
|
senderId: "u-production",
|
||||||
|
senderName: "Илья Корнеев",
|
||||||
|
text: "Можно запускать сообщение клиенту после 14:00.",
|
||||||
|
sentAt: "2026-03-12T08:25:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deliverySlots: [],
|
||||||
|
exception: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "o-1003",
|
||||||
|
orderNumber: "CD-240033",
|
||||||
|
customer: {
|
||||||
|
name: "Екатерина Тарасова",
|
||||||
|
phone: "+7 978 000-12-33",
|
||||||
|
messenger: "Макс",
|
||||||
|
address: "Севастополь, пр. Октябрьской Революции, 51",
|
||||||
|
},
|
||||||
|
status: "Проблема доставки",
|
||||||
|
deliveryAgreementStatus: "Нет ответа",
|
||||||
|
managerId: "u-manager",
|
||||||
|
logisticianIds: ["u-logistics"],
|
||||||
|
assignedDriverId: null,
|
||||||
|
createdAt: "2026-03-09T12:00:00Z",
|
||||||
|
updatedAt: "2026-03-12T07:05:00Z",
|
||||||
|
scheduledDelivery: "2026-03-13T16:00:00Z",
|
||||||
|
items: ["Шкаф-купе | 1 шт", "Зеркальные фасады | 2 шт"],
|
||||||
|
tags: ["рисковый клиент"],
|
||||||
|
comments: ["Три безуспешные попытки подтверждения"],
|
||||||
|
orderNotes: [
|
||||||
|
{
|
||||||
|
id: "note-3",
|
||||||
|
authorName: "Ольга Синицына",
|
||||||
|
text: "Если клиент выйдет на связь, предлагать только вторую половину дня.",
|
||||||
|
createdAt: "2026-03-12T07:09:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: "h-8",
|
||||||
|
action: "Запущено согласование доставки",
|
||||||
|
oldStatus: "Готов к отгрузке",
|
||||||
|
newStatus: "Ожидает согласования доставки",
|
||||||
|
userName: "Система",
|
||||||
|
at: "2026-03-11T10:05:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "h-9",
|
||||||
|
action: "Зафиксирована проблема доставки",
|
||||||
|
oldStatus: "Ожидает согласования доставки",
|
||||||
|
newStatus: "Проблема доставки",
|
||||||
|
userName: "Ольга Синицына",
|
||||||
|
at: "2026-03-12T07:05:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chatMessages: [
|
||||||
|
{
|
||||||
|
id: "c-3",
|
||||||
|
sender: "bot",
|
||||||
|
channel: "Макс",
|
||||||
|
text: "Напоминаем о необходимости выбрать дату доставки.",
|
||||||
|
sentAt: "2026-03-12T06:35:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
internalMessages: [
|
||||||
|
{
|
||||||
|
id: "ic-3",
|
||||||
|
senderId: "u-logistics",
|
||||||
|
senderName: "Ольга Синицына",
|
||||||
|
text: "Если до 18:00 клиент не ответит, переводим в ручную обработку.",
|
||||||
|
sentAt: "2026-03-12T07:08:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deliverySlots: [
|
||||||
|
{
|
||||||
|
id: "ds-3",
|
||||||
|
date: "2026-03-13",
|
||||||
|
time: "Вторая половина дня",
|
||||||
|
logisticianId: "u-logistics",
|
||||||
|
status: "Просрочен",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exception: "Отсутствие ответа клиента",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "o-1004",
|
||||||
|
orderNumber: "CD-240034",
|
||||||
|
customer: {
|
||||||
|
name: "Сергей Марченко",
|
||||||
|
phone: "+7 978 000-12-34",
|
||||||
|
messenger: "СМС",
|
||||||
|
address: "Симферополь, ул. Крылова, 4",
|
||||||
|
},
|
||||||
|
status: "Назначен водитель",
|
||||||
|
deliveryAgreementStatus: "Подтверждено клиентом",
|
||||||
|
managerId: "u-manager",
|
||||||
|
logisticianIds: ["u-logistics-2"],
|
||||||
|
assignedDriverId: "u-driver",
|
||||||
|
driverRouteOrder: 2,
|
||||||
|
createdAt: "2026-03-10T09:15:00Z",
|
||||||
|
updatedAt: "2026-03-12T11:05:00Z",
|
||||||
|
scheduledDelivery: "2026-03-14T13:00:00Z",
|
||||||
|
items: ["Столешница дуб | 1 шт", "Опоры металлические | 4 шт"],
|
||||||
|
tags: ["сегодня"],
|
||||||
|
comments: ["Доставка в офис, разгрузка через задний вход"],
|
||||||
|
orderNotes: [
|
||||||
|
{
|
||||||
|
id: "note-4",
|
||||||
|
authorName: "Павел Миронов",
|
||||||
|
text: "Назначен на рейс СМФ-02, водитель предупреждён.",
|
||||||
|
createdAt: "2026-03-12T11:06:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: "h-10",
|
||||||
|
action: "Клиент подтвердил доставку",
|
||||||
|
oldStatus: "Ожидает согласования доставки",
|
||||||
|
newStatus: "Доставка согласована",
|
||||||
|
userName: "Система",
|
||||||
|
at: "2026-03-12T09:20:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "h-11",
|
||||||
|
action: "Назначен водитель",
|
||||||
|
oldStatus: "Доставка согласована",
|
||||||
|
newStatus: "Назначен водитель",
|
||||||
|
userName: "Павел Миронов",
|
||||||
|
at: "2026-03-12T11:05:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chatMessages: [
|
||||||
|
{
|
||||||
|
id: "c-4",
|
||||||
|
sender: "bot",
|
||||||
|
channel: "СМС",
|
||||||
|
text: "Доставка подтверждена на 13 марта, вторая половина дня.",
|
||||||
|
sentAt: "2026-03-12T09:21:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
internalMessages: [
|
||||||
|
{
|
||||||
|
id: "ic-4",
|
||||||
|
senderId: "u-logistics-2",
|
||||||
|
senderName: "Павел Миронов",
|
||||||
|
text: "Водитель выезжает со склада в 13:30.",
|
||||||
|
sentAt: "2026-03-12T11:08:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deliverySlots: [
|
||||||
|
{
|
||||||
|
id: "ds-4",
|
||||||
|
date: "2026-03-14",
|
||||||
|
time: "Вторая половина дня",
|
||||||
|
logisticianId: "u-logistics-2",
|
||||||
|
status: "Подтверждён",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exception: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "o-1006",
|
||||||
|
orderNumber: "CD-240036",
|
||||||
|
customer: {
|
||||||
|
name: "Ирина Лебедева",
|
||||||
|
phone: "+7 978 000-12-36",
|
||||||
|
messenger: "Телеграм",
|
||||||
|
address: "Симферополь, ул. Киевская, 112",
|
||||||
|
},
|
||||||
|
status: "Назначен водитель",
|
||||||
|
deliveryAgreementStatus: "Подтверждено клиентом",
|
||||||
|
managerId: "u-manager",
|
||||||
|
logisticianIds: ["u-logistics"],
|
||||||
|
assignedDriverId: "u-driver",
|
||||||
|
driverRouteOrder: 1,
|
||||||
|
createdAt: "2026-03-12T10:40:00Z",
|
||||||
|
updatedAt: "2026-03-13T18:10:00Z",
|
||||||
|
scheduledDelivery: "2026-03-14T08:30:00Z",
|
||||||
|
items: ["Пенал для ванной | 1 шт", "Крепёжный комплект | 1 набор"],
|
||||||
|
tags: ["утро", "симферополь"],
|
||||||
|
comments: ["Подъезд со стороны двора, заранее позвонить консьержу"],
|
||||||
|
orderNotes: [
|
||||||
|
{
|
||||||
|
id: "note-6",
|
||||||
|
authorName: "Ольга Синицына",
|
||||||
|
text: "Клиент просит приехать в начале интервала, до 11:00.",
|
||||||
|
createdAt: "2026-03-13T18:12:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: "h-14",
|
||||||
|
action: "Клиент подтвердил доставку",
|
||||||
|
oldStatus: "Ожидает согласования доставки",
|
||||||
|
newStatus: "Доставка согласована",
|
||||||
|
userName: "Система",
|
||||||
|
at: "2026-03-13T15:20:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "h-15",
|
||||||
|
action: "Назначен водитель",
|
||||||
|
oldStatus: "Доставка согласована",
|
||||||
|
newStatus: "Назначен водитель",
|
||||||
|
userName: "Ольга Синицына",
|
||||||
|
at: "2026-03-13T18:10:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chatMessages: [
|
||||||
|
{
|
||||||
|
id: "c-6",
|
||||||
|
sender: "bot",
|
||||||
|
channel: "Телеграм",
|
||||||
|
text: "Доставка подтверждена на 14 марта, первая половина дня.",
|
||||||
|
sentAt: "2026-03-13T15:22:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
internalMessages: [
|
||||||
|
{
|
||||||
|
id: "ic-5",
|
||||||
|
senderId: "u-logistics",
|
||||||
|
senderName: "Ольга Синицына",
|
||||||
|
text: "Хрупкая упаковка, просьба выгружать аккуратно.",
|
||||||
|
sentAt: "2026-03-13T18:15:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deliverySlots: [
|
||||||
|
{
|
||||||
|
id: "ds-6",
|
||||||
|
date: "2026-03-14",
|
||||||
|
time: "Первая половина дня",
|
||||||
|
logisticianId: "u-logistics",
|
||||||
|
status: "Подтверждён",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exception: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "o-1007",
|
||||||
|
orderNumber: "CD-240037",
|
||||||
|
customer: {
|
||||||
|
name: "Дмитрий Шестаков",
|
||||||
|
phone: "+7 978 000-12-37",
|
||||||
|
messenger: "СМС",
|
||||||
|
address: "Ялта, ул. Садовая, 27",
|
||||||
|
},
|
||||||
|
status: "Загружен",
|
||||||
|
deliveryAgreementStatus: "Подтверждено клиентом",
|
||||||
|
managerId: "u-manager",
|
||||||
|
logisticianIds: ["u-logistics-2"],
|
||||||
|
assignedDriverId: "u-driver",
|
||||||
|
driverRouteOrder: 1,
|
||||||
|
createdAt: "2026-03-13T08:10:00Z",
|
||||||
|
updatedAt: "2026-03-13T19:20:00Z",
|
||||||
|
scheduledDelivery: "2026-03-15T09:30:00Z",
|
||||||
|
items: ["Комод | 1 шт", "Полка навесная | 2 шт"],
|
||||||
|
tags: ["ялта", "завтра"],
|
||||||
|
comments: ["Доставка в частный дом, разгрузка у ворот"],
|
||||||
|
orderNotes: [
|
||||||
|
{
|
||||||
|
id: "note-7",
|
||||||
|
authorName: "Павел Миронов",
|
||||||
|
text: "Машина загружена вечером заранее, выезд утром по расписанию.",
|
||||||
|
createdAt: "2026-03-13T19:22:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: "h-16",
|
||||||
|
action: "Назначен водитель",
|
||||||
|
oldStatus: "Доставка согласована",
|
||||||
|
newStatus: "Назначен водитель",
|
||||||
|
userName: "Павел Миронов",
|
||||||
|
at: "2026-03-13T16:45:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "h-17",
|
||||||
|
action: "Подтверждена загрузка",
|
||||||
|
oldStatus: "Назначен водитель",
|
||||||
|
newStatus: "Загружен",
|
||||||
|
userName: "Артём Громов",
|
||||||
|
at: "2026-03-13T19:20:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chatMessages: [
|
||||||
|
{
|
||||||
|
id: "c-7",
|
||||||
|
sender: "bot",
|
||||||
|
channel: "СМС",
|
||||||
|
text: "Доставка назначена на 15 марта, первая половина дня.",
|
||||||
|
sentAt: "2026-03-13T16:48:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
internalMessages: [
|
||||||
|
{
|
||||||
|
id: "ic-6",
|
||||||
|
senderId: "u-driver",
|
||||||
|
senderName: "Артём Громов",
|
||||||
|
text: "Принял заказ в рейс, планирую выезд в 07:30.",
|
||||||
|
sentAt: "2026-03-13T19:25:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
deliverySlots: [
|
||||||
|
{
|
||||||
|
id: "ds-7",
|
||||||
|
date: "2026-03-15",
|
||||||
|
time: "Первая половина дня",
|
||||||
|
logisticianId: "u-logistics-2",
|
||||||
|
status: "Подтверждён",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exception: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "o-1005",
|
||||||
|
orderNumber: "CD-240035",
|
||||||
|
customer: {
|
||||||
|
name: "Николай Дроздов",
|
||||||
|
phone: "+7 978 000-12-35",
|
||||||
|
messenger: "Эл. почта",
|
||||||
|
address: "Ялта, ул. Московская, 14",
|
||||||
|
},
|
||||||
|
status: "Закрыт",
|
||||||
|
deliveryAgreementStatus: "Подтверждено клиентом",
|
||||||
|
managerId: "u-manager",
|
||||||
|
logisticianIds: ["u-logistics-2"],
|
||||||
|
assignedDriverId: "u-driver",
|
||||||
|
driverRouteOrder: 1,
|
||||||
|
createdAt: "2026-03-08T09:00:00Z",
|
||||||
|
updatedAt: "2026-03-11T18:30:00Z",
|
||||||
|
scheduledDelivery: "2026-03-11T08:00:00Z",
|
||||||
|
items: ["Тумба под раковину | 1 шт", "Комплект крепежа | 1 набор"],
|
||||||
|
tags: ["архив"],
|
||||||
|
comments: ["Заказ завершён без замечаний"],
|
||||||
|
orderNotes: [
|
||||||
|
{
|
||||||
|
id: "note-5",
|
||||||
|
authorName: "Павел Миронов",
|
||||||
|
text: "Клиент подтвердил получение, заказ закрыт.",
|
||||||
|
createdAt: "2026-03-11T18:31:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: "h-12",
|
||||||
|
action: "Доставка завершена",
|
||||||
|
oldStatus: "В пути",
|
||||||
|
newStatus: "Доставлен",
|
||||||
|
userName: "Артём Громов",
|
||||||
|
at: "2026-03-11T17:50:00Z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "h-13",
|
||||||
|
action: "Заказ закрыт",
|
||||||
|
oldStatus: "Доставлен",
|
||||||
|
newStatus: "Закрыт",
|
||||||
|
userName: "Павел Миронов",
|
||||||
|
at: "2026-03-11T18:30:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chatMessages: [
|
||||||
|
{
|
||||||
|
id: "c-5",
|
||||||
|
sender: "bot",
|
||||||
|
channel: "Эл. почта",
|
||||||
|
text: "Доставка успешно завершена. Спасибо за подтверждение.",
|
||||||
|
sentAt: "2026-03-11T18:10:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
internalMessages: [],
|
||||||
|
deliverySlots: [
|
||||||
|
{
|
||||||
|
id: "ds-5",
|
||||||
|
date: "2026-03-11",
|
||||||
|
time: "Первая половина дня",
|
||||||
|
logisticianId: "u-logistics-2",
|
||||||
|
status: "Завершён",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exception: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const demoNotifications = [
|
||||||
|
{
|
||||||
|
id: "n-1",
|
||||||
|
type: "warning",
|
||||||
|
title: "Ожидается ответ клиента",
|
||||||
|
description: "CD-240031: клиенту отправлено согласование доставки, ответ ещё не получен.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "n-2",
|
||||||
|
type: "success",
|
||||||
|
title: "Доставка подтверждена",
|
||||||
|
description: "CD-240034: клиент подтвердил доставку на вторую половину дня.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "n-3",
|
||||||
|
type: "error",
|
||||||
|
title: "Нет ответа клиента",
|
||||||
|
description: "CD-240033: требуется ручная обработка логистом.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "n-4",
|
||||||
|
type: "success",
|
||||||
|
title: "Заказ готов к отгрузке",
|
||||||
|
description: "CD-240032: производство завершено, можно запускать согласование доставки.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,252 @@
|
||||||
|
import React from "react";
|
||||||
|
import { getOrderStatusComment } from "../constants/deliveryWorkflow";
|
||||||
|
import { demoNotifications, demoOrders, demoUsers } from "../data/mockAppData";
|
||||||
|
import {
|
||||||
|
reorderDriverDeliveries,
|
||||||
|
} from "../services/driverDeliveries";
|
||||||
|
import {
|
||||||
|
applyDeliveryReschedule,
|
||||||
|
applyStatusUpdate,
|
||||||
|
appendChatMessageToOrder,
|
||||||
|
appendInternalMessageToOrder,
|
||||||
|
appendOrderNote,
|
||||||
|
autoAssignOrders,
|
||||||
|
buildMetrics,
|
||||||
|
cloneOrders,
|
||||||
|
createOrderRecord,
|
||||||
|
filterOrdersByView,
|
||||||
|
updateOrderDetails,
|
||||||
|
} from "../services/orderService";
|
||||||
|
|
||||||
|
export const useOrders = (currentUser) => {
|
||||||
|
const [orders, setOrders] = React.useState(() => cloneOrders(demoOrders));
|
||||||
|
const [filters, setFilters] = React.useState({
|
||||||
|
query: "",
|
||||||
|
status: "all",
|
||||||
|
managerId: "all",
|
||||||
|
logisticianId: "all",
|
||||||
|
messenger: "all",
|
||||||
|
});
|
||||||
|
const [selectedOrderId, setSelectedOrderId] = React.useState(demoOrders[0]?.id ?? null);
|
||||||
|
const [notifications, setNotifications] = React.useState(demoNotifications);
|
||||||
|
|
||||||
|
const visibleOrders = React.useMemo(() => {
|
||||||
|
return orders.filter((order) => {
|
||||||
|
if (currentUser?.role === "manager" && order.managerId !== currentUser.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (currentUser?.role === "logistician" && !order.logisticianIds.includes(currentUser.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (currentUser?.role === "driver" && order.assignedDriverId !== currentUser.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [currentUser, orders]);
|
||||||
|
|
||||||
|
const filteredOrders = React.useMemo(() => {
|
||||||
|
return filterOrdersByView({ orders, currentUser, filters }).filteredOrders;
|
||||||
|
}, [currentUser, filters, orders]);
|
||||||
|
|
||||||
|
const selectedOrder =
|
||||||
|
filteredOrders.find((order) => order.id === selectedOrderId) ||
|
||||||
|
visibleOrders.find((order) => order.id === selectedOrderId) ||
|
||||||
|
filteredOrders[0] ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!selectedOrder && filteredOrders[0]) {
|
||||||
|
setSelectedOrderId(filteredOrders[0].id);
|
||||||
|
}
|
||||||
|
}, [filteredOrders, selectedOrder]);
|
||||||
|
|
||||||
|
const appendNotification = React.useCallback((notification) => {
|
||||||
|
setNotifications((current) => [notification, ...current].slice(0, 6));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateOrder = React.useCallback(
|
||||||
|
(orderId, updater, notificationFactory) => {
|
||||||
|
setOrders((current) =>
|
||||||
|
current.map((order) => {
|
||||||
|
if (order.id !== orderId) {
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextOrder = updater(order);
|
||||||
|
if (notificationFactory) {
|
||||||
|
appendNotification(notificationFactory(nextOrder));
|
||||||
|
}
|
||||||
|
return nextOrder;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[appendNotification],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateStatus = React.useCallback(
|
||||||
|
(orderId, nextStatus, actorName) => {
|
||||||
|
updateOrder(
|
||||||
|
orderId,
|
||||||
|
(order) => applyStatusUpdate(order, nextStatus, actorName),
|
||||||
|
(order) => ({
|
||||||
|
id: `notification-${Date.now()}`,
|
||||||
|
type: "success",
|
||||||
|
title: `Статус: ${nextStatus}`,
|
||||||
|
description: `${order.orderNumber}: ${getOrderStatusComment(nextStatus)}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[updateOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addChatMessage = React.useCallback(
|
||||||
|
(orderId, message) => {
|
||||||
|
updateOrder(
|
||||||
|
orderId,
|
||||||
|
(order) => appendChatMessageToOrder(order, message),
|
||||||
|
(order) => ({
|
||||||
|
id: `notification-${Date.now()}`,
|
||||||
|
type: message.sender === "client" ? "warning" : "success",
|
||||||
|
title: "История чата обновлена",
|
||||||
|
description: `${order.orderNumber}: ${message.channel}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[updateOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addInternalMessage = React.useCallback(
|
||||||
|
(orderId, message) => {
|
||||||
|
updateOrder(
|
||||||
|
orderId,
|
||||||
|
(order) => appendInternalMessageToOrder(order, message),
|
||||||
|
(order) => ({
|
||||||
|
id: `notification-${Date.now()}`,
|
||||||
|
type: "success",
|
||||||
|
title: "Внутренний чат обновлён",
|
||||||
|
description: `${order.orderNumber}: новое сообщение команды`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[updateOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addOrderNote = React.useCallback(
|
||||||
|
(orderId, note) => {
|
||||||
|
updateOrder(
|
||||||
|
orderId,
|
||||||
|
(order) => appendOrderNote(order, note),
|
||||||
|
(order) => ({
|
||||||
|
id: `notification-${Date.now()}`,
|
||||||
|
type: "success",
|
||||||
|
title: "Комментарий добавлен",
|
||||||
|
description: `${order.orderNumber}: новая заметка по заказу`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[updateOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reassignDelivery = React.useCallback(
|
||||||
|
(orderId, deliverySlot, actorName) => {
|
||||||
|
updateOrder(
|
||||||
|
orderId,
|
||||||
|
(order) => applyDeliveryReschedule(order, deliverySlot, actorName),
|
||||||
|
(order) => ({
|
||||||
|
id: `notification-${Date.now()}`,
|
||||||
|
type: "warning",
|
||||||
|
title: "Запрошен перенос доставки",
|
||||||
|
description: `${order.orderNumber}: новый слот ${deliverySlot.date}, ${deliverySlot.time}`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[updateOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const autoAssignLogisticians = React.useCallback(() => {
|
||||||
|
const logisticians = demoUsers.filter((user) => user.role === "logistician");
|
||||||
|
setOrders((current) => autoAssignOrders(current, logisticians));
|
||||||
|
appendNotification({
|
||||||
|
id: `notification-${Date.now()}`,
|
||||||
|
type: "success",
|
||||||
|
title: "Автораспределение выполнено",
|
||||||
|
description: `Заказы распределены между ${logisticians.length || 0} логистами`,
|
||||||
|
});
|
||||||
|
}, [appendNotification]);
|
||||||
|
|
||||||
|
const saveOrderDetails = React.useCallback(
|
||||||
|
({ orderId, payload, actorName }) => {
|
||||||
|
updateOrder(
|
||||||
|
orderId,
|
||||||
|
(order) => updateOrderDetails(order, payload, actorName),
|
||||||
|
(order) => ({
|
||||||
|
id: `notification-${Date.now()}`,
|
||||||
|
type: "success",
|
||||||
|
title: "Заказ обновлён",
|
||||||
|
description: `${order.orderNumber}: данные клиента и маршрут обновлены`,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[updateOrder],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createOrder = React.useCallback(
|
||||||
|
({ payload, actorName }) => {
|
||||||
|
const logisticians = demoUsers.filter((user) => user.role === "logistician");
|
||||||
|
const nextOrder = createOrderRecord({
|
||||||
|
payload,
|
||||||
|
actorName,
|
||||||
|
availableLogisticians: logisticians,
|
||||||
|
});
|
||||||
|
|
||||||
|
setOrders((current) => [nextOrder, ...current]);
|
||||||
|
setSelectedOrderId(nextOrder.id);
|
||||||
|
appendNotification({
|
||||||
|
id: `notification-${Date.now()}`,
|
||||||
|
type: "success",
|
||||||
|
title: "Новый заказ",
|
||||||
|
description: `${nextOrder.orderNumber}: заказ создан и ожидает подтверждения`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[appendNotification],
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveDriverRouteOrder = React.useCallback(
|
||||||
|
({ orderedIds, actorName }) => {
|
||||||
|
setOrders((current) => reorderDriverDeliveries(current, orderedIds));
|
||||||
|
appendNotification({
|
||||||
|
id: `notification-${Date.now()}`,
|
||||||
|
type: "success",
|
||||||
|
title: "Маршрут обновлён",
|
||||||
|
description: `${actorName}: последовательность доставок сохранена`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[appendNotification],
|
||||||
|
);
|
||||||
|
|
||||||
|
const metrics = React.useMemo(() => {
|
||||||
|
return buildMetrics(visibleOrders);
|
||||||
|
}, [visibleOrders]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
orders: filteredOrders,
|
||||||
|
allOrders: visibleOrders,
|
||||||
|
selectedOrder,
|
||||||
|
selectedOrderId,
|
||||||
|
setSelectedOrderId,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
notifications,
|
||||||
|
updateStatus,
|
||||||
|
addChatMessage,
|
||||||
|
addInternalMessage,
|
||||||
|
addOrderNote,
|
||||||
|
reassignDelivery,
|
||||||
|
autoAssignLogisticians,
|
||||||
|
saveOrderDetails,
|
||||||
|
createOrder,
|
||||||
|
saveDriverRouteOrder,
|
||||||
|
metrics,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,163 @@
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
const OFFLINE_READY_MESSAGE = "PWA_OFFLINE_READY";
|
||||||
|
|
||||||
|
const getBrowserWindow = () => (typeof window !== "undefined" ? window : null);
|
||||||
|
|
||||||
|
export const detectStandaloneMode = (browserWindow = getBrowserWindow()) => {
|
||||||
|
if (!browserWindow) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesDisplayMode = browserWindow.matchMedia?.("(display-mode: standalone)")?.matches;
|
||||||
|
return Boolean(matchesDisplayMode || browserWindow.navigator?.standalone);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildPwaStatusSnapshot = ({
|
||||||
|
browserWindow = getBrowserWindow(),
|
||||||
|
installPromptEvent = null,
|
||||||
|
isOnline = true,
|
||||||
|
isOfflineReady = false,
|
||||||
|
} = {}) => {
|
||||||
|
const isInstalled = detectStandaloneMode(browserWindow);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isInstalled,
|
||||||
|
isInstallAvailable: Boolean(installPromptEvent) && !isInstalled,
|
||||||
|
isOfflineReady: Boolean(isOfflineReady),
|
||||||
|
isOnline: Boolean(isOnline),
|
||||||
|
isServiceWorkerSupported: Boolean(browserWindow?.navigator?.serviceWorker),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const promptForInstall = async (installPromptEvent) => {
|
||||||
|
if (!installPromptEvent?.prompt) {
|
||||||
|
return "unavailable";
|
||||||
|
}
|
||||||
|
|
||||||
|
await installPromptEvent.prompt();
|
||||||
|
const choice = await installPromptEvent.userChoice;
|
||||||
|
return choice?.outcome || "dismissed";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usePwaStatus = () => {
|
||||||
|
const browserWindow = getBrowserWindow();
|
||||||
|
const [isOnline, setIsOnline] = React.useState(() =>
|
||||||
|
typeof navigator === "undefined" ? true : navigator.onLine,
|
||||||
|
);
|
||||||
|
const [installPromptEvent, setInstallPromptEvent] = React.useState(null);
|
||||||
|
const [isInstalled, setIsInstalled] = React.useState(() => detectStandaloneMode(browserWindow));
|
||||||
|
const [isOfflineReady, setIsOfflineReady] = React.useState(() =>
|
||||||
|
Boolean(browserWindow?.navigator?.serviceWorker?.controller),
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!browserWindow) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleOnline = () => setIsOnline(true);
|
||||||
|
const handleOffline = () => setIsOnline(false);
|
||||||
|
const handleBeforeInstallPrompt = (event) => {
|
||||||
|
event.preventDefault?.();
|
||||||
|
setInstallPromptEvent(event);
|
||||||
|
};
|
||||||
|
const handleAppInstalled = () => {
|
||||||
|
setIsInstalled(true);
|
||||||
|
setInstallPromptEvent(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
browserWindow.addEventListener("online", handleOnline);
|
||||||
|
browserWindow.addEventListener("offline", handleOffline);
|
||||||
|
browserWindow.addEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||||
|
browserWindow.addEventListener("appinstalled", handleAppInstalled);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
browserWindow.removeEventListener("online", handleOnline);
|
||||||
|
browserWindow.removeEventListener("offline", handleOffline);
|
||||||
|
browserWindow.removeEventListener("beforeinstallprompt", handleBeforeInstallPrompt);
|
||||||
|
browserWindow.removeEventListener("appinstalled", handleAppInstalled);
|
||||||
|
};
|
||||||
|
}, [browserWindow]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const serviceWorkerContainer = browserWindow?.navigator?.serviceWorker;
|
||||||
|
|
||||||
|
if (!serviceWorkerContainer) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isSubscribed = true;
|
||||||
|
const handleMessage = (event) => {
|
||||||
|
if (event.data?.type === OFFLINE_READY_MESSAGE && isSubscribed) {
|
||||||
|
setIsOfflineReady(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleControllerChange = () => {
|
||||||
|
if (isSubscribed) {
|
||||||
|
setIsOfflineReady(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
serviceWorkerContainer.addEventListener?.("message", handleMessage);
|
||||||
|
serviceWorkerContainer.addEventListener?.("controllerchange", handleControllerChange);
|
||||||
|
serviceWorkerContainer.ready
|
||||||
|
?.then(() => {
|
||||||
|
if (isSubscribed) {
|
||||||
|
setIsOfflineReady(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isSubscribed = false;
|
||||||
|
serviceWorkerContainer.removeEventListener?.("message", handleMessage);
|
||||||
|
serviceWorkerContainer.removeEventListener?.("controllerchange", handleControllerChange);
|
||||||
|
};
|
||||||
|
}, [browserWindow]);
|
||||||
|
|
||||||
|
const installApp = React.useCallback(async () => {
|
||||||
|
const outcome = await promptForInstall(installPromptEvent);
|
||||||
|
|
||||||
|
if (outcome === "accepted") {
|
||||||
|
setInstallPromptEvent(null);
|
||||||
|
setIsInstalled(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return outcome;
|
||||||
|
}, [installPromptEvent]);
|
||||||
|
|
||||||
|
const snapshot = buildPwaStatusSnapshot({
|
||||||
|
browserWindow,
|
||||||
|
installPromptEvent,
|
||||||
|
isOnline,
|
||||||
|
isOfflineReady,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
isInstalled,
|
||||||
|
installApp,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const registerPwaServiceWorker = async () => {
|
||||||
|
if (
|
||||||
|
typeof window === "undefined" ||
|
||||||
|
typeof navigator === "undefined" ||
|
||||||
|
!("serviceWorker" in navigator) ||
|
||||||
|
!import.meta.env.PROD
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await navigator.serviceWorker.register("/service-worker.js");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PWA_EVENTS = {
|
||||||
|
OFFLINE_READY_MESSAGE,
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildPwaStatusSnapshot,
|
||||||
|
detectStandaloneMode,
|
||||||
|
promptForInstall,
|
||||||
|
} from "./usePwaStatus";
|
||||||
|
import { createBeforeInstallPromptEvent, createBrowserWindow } from "../test/pwaTestUtils";
|
||||||
|
|
||||||
|
describe("usePwaStatus helpers", () => {
|
||||||
|
it("detects standalone mode through display-mode media query", () => {
|
||||||
|
const browserWindow = createBrowserWindow({ standalone: true });
|
||||||
|
|
||||||
|
expect(detectStandaloneMode(browserWindow)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds installable online snapshot when prompt is available", () => {
|
||||||
|
const browserWindow = createBrowserWindow({
|
||||||
|
standalone: false,
|
||||||
|
serviceWorker: {},
|
||||||
|
});
|
||||||
|
const installPromptEvent = createBeforeInstallPromptEvent();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildPwaStatusSnapshot({
|
||||||
|
browserWindow,
|
||||||
|
installPromptEvent,
|
||||||
|
isOnline: true,
|
||||||
|
isOfflineReady: false,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
isInstalled: false,
|
||||||
|
isInstallAvailable: true,
|
||||||
|
isOfflineReady: false,
|
||||||
|
isOnline: true,
|
||||||
|
isServiceWorkerSupported: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks app as installed and offline-ready when standalone mode is active", () => {
|
||||||
|
const browserWindow = createBrowserWindow({
|
||||||
|
standalone: true,
|
||||||
|
serviceWorker: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
buildPwaStatusSnapshot({
|
||||||
|
browserWindow,
|
||||||
|
installPromptEvent: null,
|
||||||
|
isOnline: false,
|
||||||
|
isOfflineReady: true,
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
isInstalled: true,
|
||||||
|
isInstallAvailable: false,
|
||||||
|
isOfflineReady: true,
|
||||||
|
isOnline: false,
|
||||||
|
isServiceWorkerSupported: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prompts installation and returns the user choice outcome", async () => {
|
||||||
|
const installPromptEvent = createBeforeInstallPromptEvent({ outcome: "accepted" });
|
||||||
|
|
||||||
|
await expect(promptForInstall(installPromptEvent)).resolves.toBe("accepted");
|
||||||
|
expect(installPromptEvent.getPromptCalls()).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
@import "./styles/designSystem.css";
|
||||||
|
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-width: 320px;
|
||||||
|
min-height: 100vh;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top left, rgba(80, 227, 194, 0.18), transparent 34%),
|
||||||
|
radial-gradient(circle at top right, rgba(14, 165, 233, 0.15), transparent 28%),
|
||||||
|
var(--color-base);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: "Manrope", "Segoe UI", sans-serif;
|
||||||
|
transition:
|
||||||
|
background-color 180ms ease,
|
||||||
|
color 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
button,
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
select option,
|
||||||
|
select optgroup {
|
||||||
|
background-color: var(--color-surface-strong);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
||||||
|
import React from "react";
|
||||||
|
import { ROLE_LABELS } from "../constants/roles";
|
||||||
|
import { Badge } from "../components/UI/Badge";
|
||||||
|
import { Button } from "../components/UI/Button";
|
||||||
|
import { Panel } from "../components/UI/Panel";
|
||||||
|
import { ThemeToggle } from "../components/UI/ThemeToggle";
|
||||||
|
|
||||||
|
export const AppShell = ({
|
||||||
|
user,
|
||||||
|
onSignOut,
|
||||||
|
navItems,
|
||||||
|
activeSection,
|
||||||
|
onSectionChange,
|
||||||
|
sectionMeta,
|
||||||
|
children,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen px-4 py-5 md:px-6 md:py-8">
|
||||||
|
<div className="mx-auto grid max-w-[1540px] gap-5 xl:grid-cols-[220px_1fr] xl:gap-8">
|
||||||
|
<Panel className="flex h-fit flex-col gap-5 p-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-[0.24em] text-[var(--color-text-muted)]">
|
||||||
|
Панель
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-2 text-lg font-semibold leading-tight">Управление доставкой</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.key}
|
||||||
|
className={[
|
||||||
|
"flex w-full items-center justify-between rounded-[18px] px-3 py-3 text-left text-sm transition",
|
||||||
|
activeSection === item.key
|
||||||
|
? "bg-[var(--color-accent-soft)] text-[var(--color-text)]"
|
||||||
|
: "text-[var(--color-text-muted)] hover:bg-[var(--color-surface-strong)] hover:text-[var(--color-text)]",
|
||||||
|
].join(" ")}
|
||||||
|
onClick={() => onSectionChange(item.key)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span className="font-medium">{item.label}</span>
|
||||||
|
{item.badge ? <Badge tone="accent">{item.badge}</Badge> : null}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-auto">
|
||||||
|
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
|
||||||
|
Выйти
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div className="space-y-6 xl:space-y-8">
|
||||||
|
<Panel className="p-4 md:p-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
|
||||||
|
Рабочая область
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-2xl font-semibold">{sectionMeta?.label || "Панель"}</h2>
|
||||||
|
{sectionMeta?.description ? (
|
||||||
|
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
{sectionMeta.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-sm font-medium">{user.name}</div>
|
||||||
|
<div className="text-sm text-[var(--color-text-muted)]">{ROLE_LABELS[user.role]}</div>
|
||||||
|
</div>
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
import { clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export const cn = (...inputs) => twMerge(clsx(inputs));
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import { RouterProvider } from "react-router-dom";
|
||||||
|
import { router } from "./router";
|
||||||
|
import { ThemeProvider } from "./context/ThemeContext";
|
||||||
|
import { AuthProvider } from "./context/AuthContext";
|
||||||
|
import { registerPwaServiceWorker } from "./hooks/usePwaStatus";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
registerPwaServiceWorker();
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<ThemeProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AuthProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,907 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { ORDER_STATUSES } from "../constants/orderStatuses";
|
||||||
|
import { ROLE_LABELS, ROLE_PERMISSIONS } from "../constants/roles";
|
||||||
|
import {
|
||||||
|
DRIVER_STATUSES,
|
||||||
|
getOrderStatusComment,
|
||||||
|
LOGISTICS_STATUSES,
|
||||||
|
PRODUCTION_STATUSES,
|
||||||
|
} from "../constants/deliveryWorkflow";
|
||||||
|
import { AuditPanel } from "../components/admin/AuditPanel";
|
||||||
|
import { UserDirectoryPanel } from "../components/admin/UserDirectoryPanel";
|
||||||
|
import { UserOnboardingPanel } from "../components/admin/UserOnboardingPanel";
|
||||||
|
import { DriverDeliveryDetail } from "../components/driver/DriverDeliveryDetail";
|
||||||
|
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
||||||
|
import { KpiCard } from "../components/dashboard/KpiCard";
|
||||||
|
import { PwaDemoPanel } from "../components/dashboard/PwaDemoPanel";
|
||||||
|
import { ProductionQueuePanel } from "../components/dashboard/ProductionQueuePanel";
|
||||||
|
import { RoleWorkspacePanel } from "../components/dashboard/RoleWorkspacePanel";
|
||||||
|
import { BotControlPanel } from "../components/logistics/BotControlPanel";
|
||||||
|
import { OrdersCalendarView } from "../components/orders/OrdersCalendarView";
|
||||||
|
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
|
||||||
|
import { OrderEditorPanel } from "../components/orders/OrderEditorPanel";
|
||||||
|
import { OrderFilters } from "../components/orders/OrderFilters";
|
||||||
|
import { OrdersTable } from "../components/orders/OrdersTable";
|
||||||
|
import { Badge } from "../components/UI/Badge";
|
||||||
|
import { Button } from "../components/UI/Button";
|
||||||
|
import { Modal } from "../components/UI/Modal";
|
||||||
|
import { Panel } from "../components/UI/Panel";
|
||||||
|
import { SegmentedTabs } from "../components/UI/SegmentedTabs";
|
||||||
|
import { Select } from "../components/UI/Select";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
|
import { usePwaStatus } from "../hooks/usePwaStatus";
|
||||||
|
import { useOrders } from "../hooks/useOrders";
|
||||||
|
import { AppShell } from "../layouts/AppShell";
|
||||||
|
import {
|
||||||
|
filterDriverDeliveries,
|
||||||
|
getDeliveryDay,
|
||||||
|
} from "../services/driverDeliveries";
|
||||||
|
import { buildKanbanColumns, filterArchiveOrders, filterRegistryOrders } from "../services/orderViews";
|
||||||
|
import { formatDateTime } from "../utils/formatters";
|
||||||
|
|
||||||
|
export const DashboardPage = () => {
|
||||||
|
const { user, signOut } = useAuth();
|
||||||
|
const { installApp, isInstallAvailable, isInstalled, isOfflineReady, isOnline } = usePwaStatus();
|
||||||
|
const userRole = user?.role;
|
||||||
|
const [activeSection, setActiveSection] = React.useState("overview");
|
||||||
|
const [overviewTab, setOverviewTab] = React.useState("pulse");
|
||||||
|
const [ordersViewTab, setOrdersViewTab] = React.useState("registry");
|
||||||
|
const [isOrderModalOpen, setIsOrderModalOpen] = React.useState(false);
|
||||||
|
const [isOrderWorkspaceExpanded, setIsOrderWorkspaceExpanded] = React.useState(false);
|
||||||
|
const [dragOrderId, setDragOrderId] = React.useState(null);
|
||||||
|
const [dropColumnKey, setDropColumnKey] = React.useState(null);
|
||||||
|
const [kanbanSort, setKanbanSort] = React.useState("updated_desc");
|
||||||
|
const [showCompletedInRegistry, setShowCompletedInRegistry] = React.useState(false);
|
||||||
|
const [showCompletedInKanban, setShowCompletedInKanban] = React.useState(false);
|
||||||
|
const [isCreateOrderModalOpen, setIsCreateOrderModalOpen] = React.useState(false);
|
||||||
|
const [driverFilters, setDriverFilters] = React.useState({
|
||||||
|
dateFrom: "",
|
||||||
|
dateTo: "",
|
||||||
|
city: "all",
|
||||||
|
timeSlot: "all",
|
||||||
|
viewMode: "active",
|
||||||
|
showCompleted: false,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
orders,
|
||||||
|
allOrders,
|
||||||
|
selectedOrder,
|
||||||
|
selectedOrderId,
|
||||||
|
setSelectedOrderId,
|
||||||
|
filters,
|
||||||
|
setFilters,
|
||||||
|
notifications,
|
||||||
|
updateStatus,
|
||||||
|
addChatMessage,
|
||||||
|
addInternalMessage,
|
||||||
|
addOrderNote,
|
||||||
|
reassignDelivery,
|
||||||
|
autoAssignLogisticians,
|
||||||
|
saveOrderDetails,
|
||||||
|
createOrder,
|
||||||
|
saveDriverRouteOrder,
|
||||||
|
metrics,
|
||||||
|
} = useOrders(user);
|
||||||
|
|
||||||
|
const canManageLogistics = userRole === "logistician" || userRole === "admin";
|
||||||
|
const productionOrders = allOrders.filter((order) => PRODUCTION_STATUSES.includes(order.status));
|
||||||
|
const logisticsOrders = allOrders.filter((order) => LOGISTICS_STATUSES.includes(order.status));
|
||||||
|
const driverOrders =
|
||||||
|
userRole === "driver"
|
||||||
|
? allOrders
|
||||||
|
: allOrders.filter((order) => DRIVER_STATUSES.includes(order.status));
|
||||||
|
const selectedLogisticsOrder =
|
||||||
|
logisticsOrders.find((order) => order.id === selectedOrderId) || logisticsOrders[0] || null;
|
||||||
|
const registryOrders = React.useMemo(
|
||||||
|
() => filterRegistryOrders(orders, { includeCompleted: showCompletedInRegistry }),
|
||||||
|
[orders, showCompletedInRegistry],
|
||||||
|
);
|
||||||
|
const archiveOrders = React.useMemo(() => filterArchiveOrders(orders), [orders]);
|
||||||
|
const driverPlannedOrders = React.useMemo(
|
||||||
|
() => filterDriverDeliveries(driverOrders, driverFilters),
|
||||||
|
[driverFilters, driverOrders],
|
||||||
|
);
|
||||||
|
const todayKey = React.useMemo(() => new Date().toISOString().slice(0, 10), []);
|
||||||
|
const tomorrowKey = React.useMemo(() => {
|
||||||
|
const tomorrow = new Date();
|
||||||
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
return tomorrow.toISOString().slice(0, 10);
|
||||||
|
}, []);
|
||||||
|
const driverTodayOrders = React.useMemo(
|
||||||
|
() => driverOrders.filter((order) => getDeliveryDay(order) === todayKey),
|
||||||
|
[driverOrders, todayKey],
|
||||||
|
);
|
||||||
|
const driverTomorrowOrders = React.useMemo(
|
||||||
|
() => driverOrders.filter((order) => getDeliveryDay(order) === tomorrowKey),
|
||||||
|
[driverOrders, tomorrowKey],
|
||||||
|
);
|
||||||
|
const driverCompletedOrders = React.useMemo(
|
||||||
|
() => driverOrders.filter((order) => ["Доставлен", "Закрыт"].includes(order.status)),
|
||||||
|
[driverOrders],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navItems = React.useMemo(() => {
|
||||||
|
if (userRole === "driver") {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: "overview",
|
||||||
|
label: "Обзор",
|
||||||
|
description: "Краткая сводка по вашим текущим и ближайшим доставкам.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "deliveries",
|
||||||
|
label: "Мои доставки",
|
||||||
|
description: "План маршрута, фильтры по дням и быстрые действия по доставкам.",
|
||||||
|
badge: String(driverOrders.length),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = [
|
||||||
|
{
|
||||||
|
key: "overview",
|
||||||
|
label: "Обзор",
|
||||||
|
description: "Сводка по загрузке, событиям и проблемным точкам.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "orders",
|
||||||
|
label: "Заказы",
|
||||||
|
description: "Реестр, календарь доставок, канбан, история и архив заказов.",
|
||||||
|
badge: String(allOrders.length),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (userRole === "production_lead" || userRole === "admin") {
|
||||||
|
base.push({
|
||||||
|
key: "production",
|
||||||
|
label: "Производство",
|
||||||
|
description: "Очередь производства, приоритеты и контроль готовности.",
|
||||||
|
badge: String(productionOrders.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRole === "logistician" || userRole === "admin") {
|
||||||
|
base.push({
|
||||||
|
key: "logistics",
|
||||||
|
label: "Логистика",
|
||||||
|
description: "Слоты доставки, чатботы и ручная обработка исключений.",
|
||||||
|
badge: String(logisticsOrders.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userRole === "admin") {
|
||||||
|
base.push({
|
||||||
|
key: "admin",
|
||||||
|
label: "Администрирование",
|
||||||
|
description: "Пользователи, аудит, ошибки интеграций и контроль доступа.",
|
||||||
|
badge: String(notifications.length),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
base.push({
|
||||||
|
key: "references",
|
||||||
|
label: "Справочники",
|
||||||
|
description: "Статусы заказа, роли и правила маршрутизации по процессу.",
|
||||||
|
badge: String(ORDER_STATUSES.length),
|
||||||
|
});
|
||||||
|
|
||||||
|
return base;
|
||||||
|
}, [
|
||||||
|
allOrders.length,
|
||||||
|
driverOrders.length,
|
||||||
|
logisticsOrders.length,
|
||||||
|
notifications.length,
|
||||||
|
productionOrders.length,
|
||||||
|
userRole,
|
||||||
|
]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!navItems.some((item) => item.key === activeSection)) {
|
||||||
|
setActiveSection(navItems[0]?.key || "overview");
|
||||||
|
}
|
||||||
|
}, [activeSection, navItems]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
setIsOrderWorkspaceExpanded(false);
|
||||||
|
}, [activeSection]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (activeSection !== "orders") {
|
||||||
|
setOrdersViewTab("registry");
|
||||||
|
}
|
||||||
|
}, [activeSection]);
|
||||||
|
|
||||||
|
const sectionMeta = navItems.find((item) => item.key === activeSection) || navItems[0];
|
||||||
|
|
||||||
|
const openOrderModal = (orderId) => {
|
||||||
|
setSelectedOrderId(orderId);
|
||||||
|
setIsOrderModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKanbanDrop = (column) => {
|
||||||
|
if (!dragOrderId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateStatus(dragOrderId, column.dropStatus, user.name);
|
||||||
|
setDragOrderId(null);
|
||||||
|
setDropColumnKey(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInstallApp = async () => {
|
||||||
|
await installApp();
|
||||||
|
};
|
||||||
|
|
||||||
|
const overviewTabs = [
|
||||||
|
{ key: "pulse", label: "Пульс" },
|
||||||
|
{ key: "events", label: "События" },
|
||||||
|
{ key: "exceptions", label: "Исключения" },
|
||||||
|
];
|
||||||
|
const ordersTabs = [
|
||||||
|
{ key: "registry", label: "Реестр" },
|
||||||
|
{ key: "calendar", label: "Календарь" },
|
||||||
|
{ key: "kanban", label: "Канбан" },
|
||||||
|
{ key: "history", label: "История" },
|
||||||
|
{ key: "archive", label: "Архив" },
|
||||||
|
];
|
||||||
|
const orderHistoryFeed = allOrders
|
||||||
|
.flatMap((order) =>
|
||||||
|
order.history.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
orderNumber: order.orderNumber,
|
||||||
|
customerName: order.customer.name,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.sort((left, right) => new Date(right.at) - new Date(left.at));
|
||||||
|
const sortedKanbanOrders = React.useMemo(() => {
|
||||||
|
const sortableOrders = [...orders];
|
||||||
|
const sorters = {
|
||||||
|
updated_desc: (left, right) => new Date(right.updatedAt) - new Date(left.updatedAt),
|
||||||
|
created_desc: (left, right) => new Date(right.createdAt) - new Date(left.createdAt),
|
||||||
|
delivery_asc: (left, right) =>
|
||||||
|
new Date(left.scheduledDelivery) - new Date(right.scheduledDelivery),
|
||||||
|
client_asc: (left, right) => left.customer.name.localeCompare(right.customer.name),
|
||||||
|
order_asc: (left, right) => left.orderNumber.localeCompare(right.orderNumber),
|
||||||
|
};
|
||||||
|
|
||||||
|
return sortableOrders.sort(sorters[kanbanSort] || sorters.updated_desc);
|
||||||
|
}, [kanbanSort, orders]);
|
||||||
|
const kanbanColumns = React.useMemo(
|
||||||
|
() => buildKanbanColumns(sortedKanbanOrders, { includeCompleted: showCompletedInKanban }),
|
||||||
|
[showCompletedInKanban, sortedKanbanOrders],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderOrderWorkspace = (order, isExpanded) => {
|
||||||
|
if (!order) {
|
||||||
|
return (
|
||||||
|
<Panel className="flex min-h-[320px] items-center justify-center p-6">
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">Выберите заказ из таблицы.</p>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{order.orderNumber}</h3>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
{order.customer.name} · {order.customer.address}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{!isExpanded ? (
|
||||||
|
<Button variant="secondary" onClick={() => setIsOrderWorkspaceExpanded(true)}>
|
||||||
|
Развернуть в рабочую область
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="secondary" onClick={() => setIsOrderWorkspaceExpanded(false)}>
|
||||||
|
Вернуть к таблице
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrderDetailPanel
|
||||||
|
order={order}
|
||||||
|
currentUser={user}
|
||||||
|
onStatusChange={(nextStatus) => updateStatus(order.id, nextStatus, user.name)}
|
||||||
|
onClientMessage={(text) =>
|
||||||
|
addChatMessage(order.id, {
|
||||||
|
sender: "client",
|
||||||
|
channel: order.customer.messenger,
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onInternalMessage={(message) => addInternalMessage(order.id, message)}
|
||||||
|
onOrderNote={(note) => addOrderNote(order.id, note)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderActiveTab = () => {
|
||||||
|
if (activeSection === "overview") {
|
||||||
|
if (user.role === "driver") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 xl:space-y-8">
|
||||||
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<KpiCard label="Сегодня" value={driverTodayOrders.length} hint="Точки на текущий день" />
|
||||||
|
<KpiCard label="Завтра" value={driverTomorrowOrders.length} hint="Загрузка на следующий день" />
|
||||||
|
<KpiCard label="В плане" value={driverPlannedOrders.length} hint="С учётом текущих фильтров" />
|
||||||
|
<KpiCard label="Завершено" value={driverCompletedOrders.length} hint="Финализированные доставки" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||||
|
<Panel className="space-y-5 p-6">
|
||||||
|
<h3 className="text-lg font-semibold">Как пользоваться</h3>
|
||||||
|
<div className="space-y-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
<p>1. Откройте «Мои доставки» и отфильтруйте день, город и половину дня.</p>
|
||||||
|
<p>2. Перетащите карточки внутри дня, чтобы выстроить удобный порядок точек.</p>
|
||||||
|
<p>3. Откройте карточку доставки и отметьте: загружен, в пути, доставлен или проблема.</p>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="space-y-5 p-6">
|
||||||
|
<h3 className="text-lg font-semibold">Ближайшие адреса</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{driverPlannedOrders.slice(0, 3).map((order) => (
|
||||||
|
<button
|
||||||
|
key={order.id}
|
||||||
|
className="w-full rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
|
||||||
|
onClick={() => openOrderModal(order.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{order.customer.address}</div>
|
||||||
|
<div className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{order.orderNumber} · {order.customer.name}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 xl:space-y-8">
|
||||||
|
<SegmentedTabs items={overviewTabs} activeKey={overviewTab} onChange={setOverviewTab} />
|
||||||
|
|
||||||
|
{overviewTab === "pulse" ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<KpiCard label="Активные заказы" value={metrics.total} hint="Видимость по роли" />
|
||||||
|
<KpiCard
|
||||||
|
label="Готово к отгрузке"
|
||||||
|
value={metrics.readyToShip}
|
||||||
|
hint="Можно запускать доставку"
|
||||||
|
/>
|
||||||
|
<KpiCard
|
||||||
|
label="Ждут согласования"
|
||||||
|
value={metrics.awaitingDeliveryCoordination}
|
||||||
|
hint="Клиент ещё не подтвердил доставку"
|
||||||
|
/>
|
||||||
|
<KpiCard label="Проблемные" value={metrics.exceptions} hint="Нужна ручная реакция" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]">
|
||||||
|
<RoleWorkspacePanel role={user.role} />
|
||||||
|
<Panel className="space-y-5 p-6">
|
||||||
|
<h3 className="text-lg font-semibold">Оперативные действия</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="rounded-[24px] bg-[var(--color-surface-strong)] p-5">
|
||||||
|
<div className="text-sm text-[var(--color-text-muted)]">Заказов в работе</div>
|
||||||
|
<div className="mt-3 text-2xl font-semibold">{metrics.total}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-[24px] bg-[var(--color-surface-strong)] p-5">
|
||||||
|
<div className="text-sm text-[var(--color-text-muted)]">Нужна логистика</div>
|
||||||
|
<div className="mt-3 text-2xl font-semibold">{metrics.inLogistics}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{user.role === "admin" || user.role === "logistician" ? (
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button variant="secondary" onClick={autoAssignLogisticians}>
|
||||||
|
Автораспределение логистов
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Panel>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PwaDemoPanel
|
||||||
|
isInstallAvailable={isInstallAvailable}
|
||||||
|
isInstalled={isInstalled}
|
||||||
|
isOfflineReady={isOfflineReady}
|
||||||
|
isOnline={isOnline}
|
||||||
|
onInstall={handleInstallApp}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{overviewTab === "events" ? (
|
||||||
|
<Panel className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold">Последние события</h3>
|
||||||
|
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{notifications.map((notification) => (
|
||||||
|
<div
|
||||||
|
key={notification.id}
|
||||||
|
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-semibold">{notification.title}</div>
|
||||||
|
<div className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
{notification.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{overviewTab === "exceptions" ? (
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
|
||||||
|
<AuditPanel order={selectedOrder} />
|
||||||
|
<Panel className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold">Проблемные заказы</h3>
|
||||||
|
<div className="mt-5 space-y-3">
|
||||||
|
{allOrders
|
||||||
|
.filter((order) => order.status === "Проблема доставки")
|
||||||
|
.map((order) => (
|
||||||
|
<button
|
||||||
|
key={order.id}
|
||||||
|
className="w-full rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left hover:bg-[var(--color-accent-soft)]"
|
||||||
|
onClick={() => openOrderModal(order.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{order.orderNumber}</div>
|
||||||
|
<div className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{order.customer.name} · {getOrderStatusComment(order.status)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection === "orders") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 xl:space-y-8">
|
||||||
|
<SegmentedTabs items={ordersTabs} activeKey={ordersViewTab} onChange={setOrdersViewTab} />
|
||||||
|
|
||||||
|
{ordersViewTab === "registry" ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Panel className="flex flex-wrap items-center justify-between gap-3 p-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Реестр заказов</h3>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Основная таблица для ежедневной работы. Создание заказа вынесено в отдельное действие.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={showCompletedInRegistry ? "secondary" : "ghost"}
|
||||||
|
onClick={() => setShowCompletedInRegistry((current) => !current)}
|
||||||
|
>
|
||||||
|
{showCompletedInRegistry ? "Скрыть завершённые" : "Показать завершённые"}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setIsCreateOrderModalOpen(true)}>
|
||||||
|
Добавить заказ
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
<OrderFilters filters={filters} setFilters={setFilters} />
|
||||||
|
|
||||||
|
{isOrderWorkspaceExpanded ? (
|
||||||
|
renderOrderWorkspace(selectedOrder, true)
|
||||||
|
) : (
|
||||||
|
<OrdersTable
|
||||||
|
orders={registryOrders}
|
||||||
|
selectedOrderId={selectedOrderId}
|
||||||
|
onOpenOrder={openOrderModal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ordersViewTab === "calendar" ? (
|
||||||
|
<OrdersCalendarView orders={registryOrders} onOpenOrder={openOrderModal} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ordersViewTab === "kanban" ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<OrderFilters filters={filters} setFilters={setFilters} />
|
||||||
|
|
||||||
|
<Panel className="p-4">
|
||||||
|
<div className="grid gap-3 md:grid-cols-[1fr_260px_auto] md:items-center">
|
||||||
|
<p className="text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
Канбан показывает только отфильтрованные заказы. Карточки можно перетаскивать
|
||||||
|
между столбцами, исключения вынесены отдельно, а завершённые скрыты по умолчанию.
|
||||||
|
</p>
|
||||||
|
<Select value={kanbanSort} onChange={(event) => setKanbanSort(event.target.value)}>
|
||||||
|
<option value="updated_desc">Сначала недавно обновлённые</option>
|
||||||
|
<option value="created_desc">Сначала новые заказы</option>
|
||||||
|
<option value="delivery_asc">Сначала ближайшая доставка</option>
|
||||||
|
<option value="client_asc">По имени клиента</option>
|
||||||
|
<option value="order_asc">По номеру заказа</option>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={showCompletedInKanban ? "secondary" : "ghost"}
|
||||||
|
onClick={() => setShowCompletedInKanban((current) => !current)}
|
||||||
|
>
|
||||||
|
{showCompletedInKanban ? "Скрыть завершённые" : "Показать завершённые"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"grid gap-3",
|
||||||
|
showCompletedInKanban ? "xl:grid-cols-5" : "xl:grid-cols-4",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{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-[280px] 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="w-full cursor-grab rounded-[14px] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 text-left text-[var(--color-text)] shadow-sm transition hover:border-[var(--color-accent)] hover:bg-[var(--color-accent-soft)] active:cursor-grabbing"
|
||||||
|
onClick={() => openOrderModal(order.id)}
|
||||||
|
onDragStart={() => setDragOrderId(order.id)}
|
||||||
|
onDragEnd={() => {
|
||||||
|
setDragOrderId(null);
|
||||||
|
setDropColumnKey(null);
|
||||||
|
}}
|
||||||
|
draggable
|
||||||
|
>
|
||||||
|
<div className="font-medium">{order.orderNumber}</div>
|
||||||
|
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{order.customer.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{(order.items?.[0] || "Состав не указан").split("|")[0].trim()}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-xs text-[var(--color-text-muted)]">
|
||||||
|
{order.status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ordersViewTab === "history" ? (
|
||||||
|
<Panel className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold">История заказов по сотрудникам</h3>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
Лента показывает, кто и по какому заказу менял статус или выполнял действие.
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
{orderHistoryFeed.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={`${entry.orderNumber}-${entry.id}`}
|
||||||
|
className="rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{entry.orderNumber} · {entry.customerName}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{entry.userName} · {entry.action}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
{formatDateTime(entry.at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{entry.oldStatus || "Начало"} → {entry.newStatus}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{ordersViewTab === "archive" ? (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Panel className="flex flex-wrap items-center justify-between gap-3 p-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Архив заказов</h3>
|
||||||
|
<p className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Завершённые заказы вынесены отдельно, чтобы не перегружать реестр и канбан.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="neutral">{archiveOrders.length}</Badge>
|
||||||
|
</Panel>
|
||||||
|
<OrdersTable
|
||||||
|
orders={archiveOrders}
|
||||||
|
selectedOrderId={selectedOrderId}
|
||||||
|
onOpenOrder={openOrderModal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection === "production") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 xl:space-y-8">
|
||||||
|
<ProductionQueuePanel orders={allOrders} />
|
||||||
|
|
||||||
|
<OrdersTable
|
||||||
|
orders={productionOrders}
|
||||||
|
selectedOrderId={selectedOrderId}
|
||||||
|
onOpenOrder={openOrderModal}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection === "logistics") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 xl:space-y-8">
|
||||||
|
<section className="grid gap-6 xl:grid-cols-[1.08fr_0.92fr]">
|
||||||
|
<BotControlPanel
|
||||||
|
selectedOrder={selectedLogisticsOrder}
|
||||||
|
canManageLogistics={canManageLogistics}
|
||||||
|
onSendBotMessage={(message) =>
|
||||||
|
selectedLogisticsOrder && addChatMessage(selectedLogisticsOrder.id, message)
|
||||||
|
}
|
||||||
|
onReschedule={(slot) =>
|
||||||
|
selectedLogisticsOrder &&
|
||||||
|
reassignDelivery(selectedLogisticsOrder.id, slot, user.name)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Panel className="space-y-5 p-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">Логика каналов</h3>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
Единый интеграционный слой сохраняет входящие и исходящие события в историю
|
||||||
|
заказа, а ответы клиента автоматически меняют статус согласования доставки.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
<p>СМС и электронная почта: быстрый старт для первого подтверждения доставки.</p>
|
||||||
|
<p>ВКонтакте: обратные вызовы и кнопки с привязкой к идентификатору заказа.</p>
|
||||||
|
<p>Макс: преобразование входящих событий в единую модель чата.</p>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<Panel className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold">Готовые заказы и слоты</h3>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
Для логистики здесь остаются только заказы, где нужно согласование, перенос или
|
||||||
|
ручная реакция на исключения.
|
||||||
|
</p>
|
||||||
|
<div className="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{logisticsOrders.map((order) => (
|
||||||
|
<button
|
||||||
|
key={order.id}
|
||||||
|
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5 text-left transition hover:bg-[var(--color-accent-soft)]"
|
||||||
|
onClick={() => setSelectedOrderId(order.id)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="font-semibold">{order.orderNumber}</div>
|
||||||
|
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{order.customer.name} · {order.customer.messenger}
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-sm text-[var(--color-text)]">{order.status}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection === "references") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 xl:space-y-8">
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<Panel className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold">Статусы заказа</h3>
|
||||||
|
<div className="mt-5 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{ORDER_STATUSES.map((status, index) => (
|
||||||
|
<div
|
||||||
|
key={status}
|
||||||
|
className="rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
|
||||||
|
>
|
||||||
|
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
|
Шаг {index + 1}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm font-medium">{status}</div>
|
||||||
|
<div className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
|
{getOrderStatusComment(status)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="p-6">
|
||||||
|
<h3 className="text-lg font-semibold">Роли и зоны ответственности</h3>
|
||||||
|
<div className="mt-5 space-y-4">
|
||||||
|
{Object.entries(ROLE_LABELS).map(([roleKey, roleLabel]) => (
|
||||||
|
<div
|
||||||
|
key={roleKey}
|
||||||
|
className="rounded-[22px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
|
||||||
|
>
|
||||||
|
<div className="font-medium">{roleLabel}</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{ROLE_PERMISSIONS[roleKey].map((permission) => (
|
||||||
|
<span
|
||||||
|
key={permission}
|
||||||
|
className="rounded-full bg-[var(--color-accent-soft)] px-3 py-1 text-xs text-[var(--color-text)]"
|
||||||
|
>
|
||||||
|
{permission}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection === "deliveries") {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 xl:space-y-8">
|
||||||
|
<DriverDeliveryPlanner
|
||||||
|
orders={driverOrders}
|
||||||
|
filters={driverFilters}
|
||||||
|
setFilters={setDriverFilters}
|
||||||
|
onOpenOrder={openOrderModal}
|
||||||
|
onStatusChange={(orderId, nextStatus) => updateStatus(orderId, nextStatus, user.name)}
|
||||||
|
onReorder={(orderedIds) =>
|
||||||
|
saveDriverRouteOrder({
|
||||||
|
orderedIds,
|
||||||
|
actorName: user.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 xl:space-y-8">
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[1fr_1fr]">
|
||||||
|
<AuditPanel order={selectedOrder} />
|
||||||
|
<UserDirectoryPanel currentUser={user} />
|
||||||
|
</div>
|
||||||
|
<UserOnboardingPanel />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
user={user}
|
||||||
|
onSignOut={signOut}
|
||||||
|
navItems={navItems}
|
||||||
|
activeSection={activeSection}
|
||||||
|
onSectionChange={setActiveSection}
|
||||||
|
sectionMeta={sectionMeta}
|
||||||
|
>
|
||||||
|
{renderActiveTab()}
|
||||||
|
<Modal isOpen={isOrderModalOpen} onClose={() => setIsOrderModalOpen(false)}>
|
||||||
|
{user.role === "driver" ? (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold">Карточка доставки</h3>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Только нужные водителю данные: адрес, клиент, состав заказа и быстрые действия.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={() => setIsOrderModalOpen(false)}>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DriverDeliveryDetail
|
||||||
|
order={selectedOrder}
|
||||||
|
onStatusChange={(nextStatus) =>
|
||||||
|
selectedOrder && updateStatus(selectedOrder.id, nextStatus, user.name)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold">Карточка заказа</h3>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Просмотр без потери контекста списка. При необходимости можно раскрыть в рабочую область.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
setIsOrderModalOpen(false);
|
||||||
|
setActiveSection("orders");
|
||||||
|
setIsOrderWorkspaceExpanded(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Раскрыть полностью
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" onClick={() => setIsOrderModalOpen(false)}>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderOrderWorkspace(selectedOrder, false)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
isOpen={isCreateOrderModalOpen}
|
||||||
|
onClose={() => setIsCreateOrderModalOpen(false)}
|
||||||
|
className="max-w-[920px]"
|
||||||
|
>
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-xl font-semibold">Создать заказ</h3>
|
||||||
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Короткая форма создания без перегруза основного раздела заказов.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" onClick={() => setIsCreateOrderModalOpen(false)}>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<OrderEditorPanel
|
||||||
|
currentUser={user}
|
||||||
|
selectedOrder={null}
|
||||||
|
onCreateOrder={createOrder}
|
||||||
|
onSaveOrder={saveOrderDetails}
|
||||||
|
createOnly
|
||||||
|
onDone={() => setIsCreateOrderModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Navigate } from "react-router-dom";
|
||||||
|
import { OtpLoginForm } from "../components/auth/OtpLoginForm";
|
||||||
|
import { DEMO_LOGIN_EMAIL, resolveLoginEmail, useAuth } from "../context/AuthContext";
|
||||||
|
|
||||||
|
export const LoginPage = () => {
|
||||||
|
const { user, isOtpSent, isLoading, isDemoMode, requestOtp, verifyOtp } = useAuth();
|
||||||
|
const [email, setEmail] = React.useState(() => (isDemoMode ? DEMO_LOGIN_EMAIL : ""));
|
||||||
|
const [roleHint, setRoleHint] = React.useState("manager");
|
||||||
|
const [otp, setOtp] = React.useState("");
|
||||||
|
const [error, setError] = React.useState("");
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isDemoMode) {
|
||||||
|
setEmail(DEMO_LOGIN_EMAIL);
|
||||||
|
}
|
||||||
|
}, [isDemoMode]);
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRequestOtp = async () => {
|
||||||
|
const response = await requestOtp({ email: resolveLoginEmail(isDemoMode, email), roleHint });
|
||||||
|
if (!response.success) {
|
||||||
|
setError(response.error?.message || "Не удалось отправить код");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyOtp = async () => {
|
||||||
|
const response = await verifyOtp({ email: resolveLoginEmail(isDemoMode, email), otp });
|
||||||
|
if (!response.success) {
|
||||||
|
setError(response.error?.message || "Не удалось подтвердить код");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setError("");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center px-4 py-10">
|
||||||
|
<OtpLoginForm
|
||||||
|
email={email}
|
||||||
|
setEmail={setEmail}
|
||||||
|
roleHint={roleHint}
|
||||||
|
setRoleHint={setRoleHint}
|
||||||
|
otp={otp}
|
||||||
|
setOtp={setOtp}
|
||||||
|
isOtpSent={isOtpSent}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isDemoMode={isDemoMode}
|
||||||
|
onRequestOtp={handleRequestOtp}
|
||||||
|
onVerifyOtp={handleVerifyOtp}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Button } from "../components/UI/Button";
|
||||||
|
import { Panel } from "../components/UI/Panel";
|
||||||
|
|
||||||
|
export const NotFoundPage = () => {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center px-4">
|
||||||
|
<Panel className="max-w-lg p-8 text-center">
|
||||||
|
<h1 className="text-3xl font-semibold">Страница не найдена</h1>
|
||||||
|
<p className="mt-3 text-sm text-[var(--color-text-muted)]">
|
||||||
|
Вернитесь к панели управления заказами и доставкой.
|
||||||
|
</p>
|
||||||
|
<Link className="mt-6 inline-flex" to="/dashboard">
|
||||||
|
<Button>К дашборду</Button>
|
||||||
|
</Link>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Navigate, createBrowserRouter } from "react-router-dom";
|
||||||
|
import App from "./App";
|
||||||
|
import { DashboardPage } from "./pages/DashboardPage";
|
||||||
|
import { LoginPage } from "./pages/LoginPage";
|
||||||
|
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||||
|
|
||||||
|
export const router = createBrowserRouter([
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
element: <App />,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
index: true,
|
||||||
|
element: <Navigate to="/dashboard" replace />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "login",
|
||||||
|
element: <LoginPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "dashboard",
|
||||||
|
element: <DashboardPage />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "*",
|
||||||
|
element: <NotFoundPage />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
const ACTIVE_DRIVER_STATUSES = new Set(["Назначен водитель", "Загружен", "В пути"]);
|
||||||
|
const COMPLETED_DRIVER_STATUSES = new Set(["Доставлен", "Закрыт"]);
|
||||||
|
const PROBLEM_DRIVER_STATUSES = new Set(["Проблема доставки"]);
|
||||||
|
const DRIVER_KANBAN_COLUMNS = [
|
||||||
|
{
|
||||||
|
key: "assigned",
|
||||||
|
title: "Назначен",
|
||||||
|
statuses: ["Назначен водитель"],
|
||||||
|
dropStatus: "Назначен водитель",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "loaded",
|
||||||
|
title: "Загружен",
|
||||||
|
statuses: ["Загружен"],
|
||||||
|
dropStatus: "Загружен",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "on_route",
|
||||||
|
title: "В пути",
|
||||||
|
statuses: ["В пути"],
|
||||||
|
dropStatus: "В пути",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "delivered",
|
||||||
|
title: "Доставлен",
|
||||||
|
statuses: ["Доставлен", "Закрыт"],
|
||||||
|
dropStatus: "Доставлен",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "problem",
|
||||||
|
title: "Проблема",
|
||||||
|
statuses: ["Проблема доставки"],
|
||||||
|
dropStatus: "Проблема доставки",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getDeliveryDay = (order) =>
|
||||||
|
order.deliverySlots?.[0]?.date || order.scheduledDelivery?.slice(0, 10) || "";
|
||||||
|
|
||||||
|
export const getDeliveryCity = (order) =>
|
||||||
|
order.customer.address?.split(",")[0]?.trim() || "Без города";
|
||||||
|
|
||||||
|
export const getDeliveryHalfDay = (order) => {
|
||||||
|
if (order.deliverySlots?.[0]?.time) {
|
||||||
|
return order.deliverySlots[0].time;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deliveryHour = Number(order.scheduledDelivery?.slice(11, 13) || 0);
|
||||||
|
return deliveryHour >= 12 ? "Вторая половина дня" : "Первая половина дня";
|
||||||
|
};
|
||||||
|
|
||||||
|
const isWithinDateRange = (order, dateFrom, dateTo) => {
|
||||||
|
const deliveryDay = getDeliveryDay(order);
|
||||||
|
|
||||||
|
if (dateFrom && deliveryDay < dateFrom) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dateTo && deliveryDay > dateTo) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isIncludedByView = (order, viewMode, showCompleted) => {
|
||||||
|
if (viewMode === "all") {
|
||||||
|
return showCompleted || !COMPLETED_DRIVER_STATUSES.has(order.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewMode === "active") {
|
||||||
|
return ACTIVE_DRIVER_STATUSES.has(order.status) || (showCompleted && COMPLETED_DRIVER_STATUSES.has(order.status));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewMode === "problems") {
|
||||||
|
return PROBLEM_DRIVER_STATUSES.has(order.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterDriverDeliveries = (orders, filters) => {
|
||||||
|
const {
|
||||||
|
dateFrom = "",
|
||||||
|
dateTo = "",
|
||||||
|
city = "all",
|
||||||
|
timeSlot = "all",
|
||||||
|
viewMode = "active",
|
||||||
|
showCompleted = false,
|
||||||
|
} = filters;
|
||||||
|
|
||||||
|
return orders.filter((order) => {
|
||||||
|
if (!isWithinDateRange(order, dateFrom, dateTo)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (city !== "all" && getDeliveryCity(order) !== city) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timeSlot !== "all" && getDeliveryHalfDay(order) !== timeSlot) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return isIncludedByView(order, viewMode, showCompleted);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const compareByRouteOrder = (left, right) => {
|
||||||
|
const leftOrder = left.driverRouteOrder ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
const rightOrder = right.driverRouteOrder ?? Number.MAX_SAFE_INTEGER;
|
||||||
|
|
||||||
|
if (leftOrder !== rightOrder) {
|
||||||
|
return leftOrder - rightOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(left.scheduledDelivery) - new Date(right.scheduledDelivery);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const groupDriverDeliveriesByDate = (orders) => {
|
||||||
|
const grouped = orders.reduce((accumulator, order) => {
|
||||||
|
const date = getDeliveryDay(order);
|
||||||
|
accumulator[date] = accumulator[date] || [];
|
||||||
|
accumulator[date].push(order);
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return Object.entries(grouped)
|
||||||
|
.sort(([leftDate], [rightDate]) => leftDate.localeCompare(rightDate))
|
||||||
|
.map(([date, items]) => ({
|
||||||
|
date,
|
||||||
|
items: [...items].sort(compareByRouteOrder),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const reorderDriverDeliveries = (orders, orderedIds) => {
|
||||||
|
const routeIndexMap = new Map(orderedIds.map((id, index) => [id, index + 1]));
|
||||||
|
|
||||||
|
return orders.map((order) =>
|
||||||
|
routeIndexMap.has(order.id)
|
||||||
|
? {
|
||||||
|
...order,
|
||||||
|
driverRouteOrder: routeIndexMap.get(order.id),
|
||||||
|
}
|
||||||
|
: order,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getDriverCities = (orders) =>
|
||||||
|
[...new Set(orders.map((order) => getDeliveryCity(order)).filter(Boolean))].sort((left, right) =>
|
||||||
|
left.localeCompare(right),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const buildDriverKanbanColumns = (orders) =>
|
||||||
|
DRIVER_KANBAN_COLUMNS.map((column) => ({
|
||||||
|
...column,
|
||||||
|
items: orders.filter((order) => column.statuses.includes(order.status)).sort(compareByRouteOrder),
|
||||||
|
}));
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildDriverKanbanColumns,
|
||||||
|
filterDriverDeliveries,
|
||||||
|
getDeliveryCity,
|
||||||
|
getDeliveryHalfDay,
|
||||||
|
groupDriverDeliveriesByDate,
|
||||||
|
reorderDriverDeliveries,
|
||||||
|
} from "./driverDeliveries";
|
||||||
|
|
||||||
|
const driverOrders = [
|
||||||
|
{
|
||||||
|
id: "d-1",
|
||||||
|
status: "Назначен водитель",
|
||||||
|
scheduledDelivery: "2026-03-14T08:30:00Z",
|
||||||
|
driverRouteOrder: 2,
|
||||||
|
customer: {
|
||||||
|
address: "Симферополь, ул. Тургенева, 18",
|
||||||
|
},
|
||||||
|
deliverySlots: [
|
||||||
|
{
|
||||||
|
date: "2026-03-14",
|
||||||
|
time: "Первая половина дня",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "d-2",
|
||||||
|
status: "В пути",
|
||||||
|
scheduledDelivery: "2026-03-14T13:00:00Z",
|
||||||
|
driverRouteOrder: 1,
|
||||||
|
customer: {
|
||||||
|
address: "Симферополь, ул. Крылова, 4",
|
||||||
|
},
|
||||||
|
deliverySlots: [
|
||||||
|
{
|
||||||
|
date: "2026-03-14",
|
||||||
|
time: "Вторая половина дня",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "d-3",
|
||||||
|
status: "Доставлен",
|
||||||
|
scheduledDelivery: "2026-03-15T09:00:00Z",
|
||||||
|
driverRouteOrder: 1,
|
||||||
|
customer: {
|
||||||
|
address: "Ялта, ул. Московская, 14",
|
||||||
|
},
|
||||||
|
deliverySlots: [
|
||||||
|
{
|
||||||
|
date: "2026-03-15",
|
||||||
|
time: "Первая половина дня",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("driverDeliveries helpers", () => {
|
||||||
|
it("extracts city from delivery address", () => {
|
||||||
|
expect(getDeliveryCity(driverOrders[0])).toBe("Симферополь");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives half-day from delivery slot", () => {
|
||||||
|
expect(getDeliveryHalfDay(driverOrders[1])).toBe("Вторая половина дня");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters deliveries by date range, city, half-day and view mode", () => {
|
||||||
|
const filtered = filterDriverDeliveries(driverOrders, {
|
||||||
|
dateFrom: "2026-03-14",
|
||||||
|
dateTo: "2026-03-14",
|
||||||
|
city: "Симферополь",
|
||||||
|
timeSlot: "all",
|
||||||
|
viewMode: "active",
|
||||||
|
showCompleted: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filtered.map((order) => order.id)).toEqual(["d-1", "d-2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("can include completed deliveries back into the main list", () => {
|
||||||
|
const filtered = filterDriverDeliveries(driverOrders, {
|
||||||
|
dateFrom: "",
|
||||||
|
dateTo: "",
|
||||||
|
city: "all",
|
||||||
|
timeSlot: "all",
|
||||||
|
viewMode: "active",
|
||||||
|
showCompleted: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(filtered.map((order) => order.id)).toEqual(["d-1", "d-2", "d-3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups deliveries by day and sorts them by route order", () => {
|
||||||
|
const grouped = groupDriverDeliveriesByDate(driverOrders);
|
||||||
|
|
||||||
|
expect(grouped).toHaveLength(2);
|
||||||
|
expect(grouped[0].date).toBe("2026-03-14");
|
||||||
|
expect(grouped[0].items.map((order) => order.id)).toEqual(["d-2", "d-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reorders deliveries and rewrites route positions", () => {
|
||||||
|
const reordered = reorderDriverDeliveries(driverOrders, ["d-1", "d-2"]);
|
||||||
|
|
||||||
|
const first = reordered.find((order) => order.id === "d-1");
|
||||||
|
const second = reordered.find((order) => order.id === "d-2");
|
||||||
|
|
||||||
|
expect(first.driverRouteOrder).toBe(1);
|
||||||
|
expect(second.driverRouteOrder).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds driver kanban columns from filtered deliveries", () => {
|
||||||
|
const columns = buildDriverKanbanColumns(driverOrders);
|
||||||
|
|
||||||
|
expect(columns.map((column) => column.key)).toEqual([
|
||||||
|
"assigned",
|
||||||
|
"loaded",
|
||||||
|
"on_route",
|
||||||
|
"delivered",
|
||||||
|
"problem",
|
||||||
|
]);
|
||||||
|
expect(columns[0].items.map((order) => order.id)).toEqual(["d-1"]);
|
||||||
|
expect(columns[2].items.map((order) => order.id)).toEqual(["d-2"]);
|
||||||
|
expect(columns[3].items.map((order) => order.id)).toEqual(["d-3"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,333 @@
|
||||||
|
import { demoOrders } from "../data/mockAppData";
|
||||||
|
import {
|
||||||
|
getAvailableTransitionsByRole,
|
||||||
|
LOGISTICS_STATUSES,
|
||||||
|
PRODUCTION_STATUSES,
|
||||||
|
} from "../constants/deliveryWorkflow";
|
||||||
|
|
||||||
|
export const cloneOrders = (orders = demoOrders) =>
|
||||||
|
orders.map((order) => ({
|
||||||
|
...order,
|
||||||
|
customer: { ...order.customer },
|
||||||
|
history: [...order.history],
|
||||||
|
chatMessages: [...order.chatMessages],
|
||||||
|
internalMessages: [...(order.internalMessages || [])],
|
||||||
|
orderNotes: [...(order.orderNotes || [])],
|
||||||
|
deliverySlots: [...order.deliverySlots],
|
||||||
|
logisticianIds: [...order.logisticianIds],
|
||||||
|
assignedDriverId: order.assignedDriverId || null,
|
||||||
|
driverRouteOrder: order.driverRouteOrder ?? null,
|
||||||
|
deliveryAgreementStatus: order.deliveryAgreementStatus || "Не начато",
|
||||||
|
comments: [...order.comments],
|
||||||
|
items: [...(order.items || [])],
|
||||||
|
tags: [...order.tags],
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const buildSearchBlob = (order) => {
|
||||||
|
return [
|
||||||
|
order.orderNumber,
|
||||||
|
order.customer.name,
|
||||||
|
order.customer.phone,
|
||||||
|
order.status,
|
||||||
|
order.deliveryAgreementStatus,
|
||||||
|
order.exception || "",
|
||||||
|
order.customer.messenger,
|
||||||
|
...(order.items || []),
|
||||||
|
...order.comments,
|
||||||
|
...order.tags,
|
||||||
|
]
|
||||||
|
.join(" ")
|
||||||
|
.toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const filterOrdersByView = ({ orders, currentUser, filters }) => {
|
||||||
|
const visibleOrders = orders.filter((order) => {
|
||||||
|
if (currentUser?.role === "manager" && order.managerId !== currentUser.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (currentUser?.role === "logistician" && !order.logisticianIds.includes(currentUser.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (currentUser?.role === "driver" && order.assignedDriverId !== currentUser.id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const normalizedQuery = filters.query.trim().toLowerCase();
|
||||||
|
const filteredOrders = visibleOrders.filter((order) => {
|
||||||
|
if (filters.status !== "all" && order.status !== filters.status) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filters.managerId !== "all" && order.managerId !== filters.managerId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filters.logisticianId !== "all" && !order.logisticianIds.includes(filters.logisticianId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (filters.messenger !== "all" && order.customer.messenger !== filters.messenger) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizedQuery && !buildSearchBlob(order).includes(normalizedQuery)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { visibleOrders, filteredOrders };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const appendHistoryEntry = (order, entry) => ({
|
||||||
|
...order,
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: `history-${Date.now()}`,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
...entry,
|
||||||
|
},
|
||||||
|
...order.history,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const getAvailableTransitions = ({ status, role }) =>
|
||||||
|
getAvailableTransitionsByRole({ status, role });
|
||||||
|
|
||||||
|
const deriveAgreementStatus = (currentOrder, nextStatus) => {
|
||||||
|
if (nextStatus === "Ожидает согласования доставки") {
|
||||||
|
return currentOrder.deliveryAgreementStatus === "Подтверждено клиентом"
|
||||||
|
? "Отправлено клиенту"
|
||||||
|
: "Ожидание ответа";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStatus === "Доставка согласована") {
|
||||||
|
return "Подтверждено клиентом";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStatus === "Проблема доставки") {
|
||||||
|
return currentOrder.deliveryAgreementStatus === "Не начато"
|
||||||
|
? "Ошибка отправки"
|
||||||
|
: currentOrder.deliveryAgreementStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentOrder.deliveryAgreementStatus || "Не начато";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const applyStatusUpdate = (order, nextStatus, actorName) => {
|
||||||
|
return appendHistoryEntry(
|
||||||
|
{
|
||||||
|
...order,
|
||||||
|
status: nextStatus,
|
||||||
|
deliveryAgreementStatus: deriveAgreementStatus(order, nextStatus),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "Изменение статуса",
|
||||||
|
oldStatus: order.status,
|
||||||
|
newStatus: nextStatus,
|
||||||
|
userName: actorName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const appendChatMessageToOrder = (order, message) => ({
|
||||||
|
...order,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
chatMessages: [
|
||||||
|
{
|
||||||
|
id: `chat-${Date.now()}`,
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
...message,
|
||||||
|
},
|
||||||
|
...order.chatMessages,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appendInternalMessageToOrder = (order, message) => ({
|
||||||
|
...order,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
internalMessages: [
|
||||||
|
{
|
||||||
|
id: `internal-${Date.now()}`,
|
||||||
|
sentAt: new Date().toISOString(),
|
||||||
|
...message,
|
||||||
|
},
|
||||||
|
...(order.internalMessages || []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const appendOrderNote = (order, note) => ({
|
||||||
|
...order,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
orderNotes: [
|
||||||
|
{
|
||||||
|
id: `note-${Date.now()}`,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
...note,
|
||||||
|
},
|
||||||
|
...(order.orderNotes || []),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
export const applyDeliveryReschedule = (order, deliverySlot, actorName) => {
|
||||||
|
const normalizedTime =
|
||||||
|
deliverySlot.time === "Вторая половина дня" ? "13:00:00Z" : "09:00:00Z";
|
||||||
|
return appendHistoryEntry(
|
||||||
|
{
|
||||||
|
...order,
|
||||||
|
status: "Ожидает согласования доставки",
|
||||||
|
deliveryAgreementStatus: "Перенос запрошен",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
scheduledDelivery: `${deliverySlot.date}T${normalizedTime}`,
|
||||||
|
deliverySlots: [
|
||||||
|
{
|
||||||
|
id: `slot-${Date.now()}`,
|
||||||
|
...deliverySlot,
|
||||||
|
status: "Ожидает подтверждения",
|
||||||
|
},
|
||||||
|
...order.deliverySlots,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "Перенос доставки",
|
||||||
|
oldStatus: order.status,
|
||||||
|
newStatus: "Ожидает согласования доставки",
|
||||||
|
userName: actorName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateOrderDetails = (order, payload, actorName) => {
|
||||||
|
return appendHistoryEntry(
|
||||||
|
{
|
||||||
|
...order,
|
||||||
|
customer: {
|
||||||
|
...order.customer,
|
||||||
|
name: payload.customerName,
|
||||||
|
phone: payload.customerPhone,
|
||||||
|
address: payload.customerAddress,
|
||||||
|
messenger: payload.messenger,
|
||||||
|
},
|
||||||
|
orderNumber: payload.orderNumber,
|
||||||
|
managerId: payload.managerId,
|
||||||
|
items: payload.items
|
||||||
|
.split("\n")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
comments: payload.comments
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
tags: payload.tags
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "Редактирование заказа",
|
||||||
|
oldStatus: order.status,
|
||||||
|
newStatus: order.status,
|
||||||
|
userName: actorName,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createOrderRecord = ({ payload, actorName, availableLogisticians = [] }) => {
|
||||||
|
const assignedLogistician = availableLogisticians[0]?.id || null;
|
||||||
|
return {
|
||||||
|
id: `order-${Date.now()}`,
|
||||||
|
orderNumber: payload.orderNumber,
|
||||||
|
customer: {
|
||||||
|
name: payload.customerName,
|
||||||
|
phone: payload.customerPhone,
|
||||||
|
address: payload.customerAddress,
|
||||||
|
messenger: payload.messenger,
|
||||||
|
},
|
||||||
|
status: "Новый",
|
||||||
|
managerId: payload.managerId,
|
||||||
|
logisticianIds: assignedLogistician ? [assignedLogistician] : [],
|
||||||
|
assignedDriverId: null,
|
||||||
|
driverRouteOrder: null,
|
||||||
|
deliveryAgreementStatus: "Не начато",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
scheduledDelivery: payload.deliveryDate
|
||||||
|
? `${payload.deliveryDate}T10:00:00Z`
|
||||||
|
: new Date().toISOString(),
|
||||||
|
tags: payload.tags
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
items: payload.items
|
||||||
|
.split("\n")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
comments: payload.comments
|
||||||
|
.split(",")
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: `history-${Date.now()}`,
|
||||||
|
action: "Создание заказа",
|
||||||
|
oldStatus: null,
|
||||||
|
newStatus: "Новый",
|
||||||
|
userName: actorName,
|
||||||
|
at: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
chatMessages: [],
|
||||||
|
internalMessages: [],
|
||||||
|
orderNotes: [],
|
||||||
|
deliverySlots: payload.deliveryDate
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: `slot-${Date.now()}`,
|
||||||
|
date: payload.deliveryDate,
|
||||||
|
time: "Первая половина дня",
|
||||||
|
logisticianId: assignedLogistician,
|
||||||
|
status: "Черновик",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
exception: null,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const autoAssignOrders = (orders, logisticians) => {
|
||||||
|
if (!logisticians.length) {
|
||||||
|
return orders;
|
||||||
|
}
|
||||||
|
|
||||||
|
return orders.map((order, index) =>
|
||||||
|
appendHistoryEntry(
|
||||||
|
{
|
||||||
|
...order,
|
||||||
|
logisticianIds: [logisticians[index % logisticians.length].id],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "Автораспределение логиста",
|
||||||
|
oldStatus: order.status,
|
||||||
|
newStatus: order.status,
|
||||||
|
userName: "Система",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildMetrics = (orders) => {
|
||||||
|
const byStatus = orders.reduce((accumulator, order) => {
|
||||||
|
accumulator[order.status] = (accumulator[order.status] || 0) + 1;
|
||||||
|
return accumulator;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: orders.length,
|
||||||
|
readyToShip: byStatus["Готов к отгрузке"] || 0,
|
||||||
|
awaitingDeliveryCoordination: byStatus["Ожидает согласования доставки"] || 0,
|
||||||
|
inProduction: PRODUCTION_STATUSES.reduce((sum, status) => sum + (byStatus[status] || 0), 0),
|
||||||
|
inLogistics: LOGISTICS_STATUSES.reduce((sum, status) => sum + (byStatus[status] || 0), 0),
|
||||||
|
exceptions: byStatus["Проблема доставки"] || 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { demoOrders, demoUsers } from "../data/mockAppData";
|
||||||
|
import {
|
||||||
|
applyStatusUpdate,
|
||||||
|
autoAssignOrders,
|
||||||
|
buildMetrics,
|
||||||
|
cloneOrders,
|
||||||
|
createOrderRecord,
|
||||||
|
filterOrdersByView,
|
||||||
|
getAvailableTransitions,
|
||||||
|
} from "./orderService";
|
||||||
|
|
||||||
|
describe("orderService", () => {
|
||||||
|
it("filters manager orders to owned items only", () => {
|
||||||
|
const result = filterOrdersByView({
|
||||||
|
orders: cloneOrders(demoOrders),
|
||||||
|
currentUser: demoUsers[0],
|
||||||
|
filters: {
|
||||||
|
query: "",
|
||||||
|
status: "all",
|
||||||
|
managerId: "all",
|
||||||
|
logisticianId: "all",
|
||||||
|
messenger: "all",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.visibleOrders).toHaveLength(7);
|
||||||
|
expect(result.filteredOrders.every((order) => order.managerId === demoUsers[0].id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters driver orders to assigned deliveries only", () => {
|
||||||
|
const driver = demoUsers.find((user) => user.role === "driver");
|
||||||
|
const result = filterOrdersByView({
|
||||||
|
orders: cloneOrders(demoOrders),
|
||||||
|
currentUser: driver,
|
||||||
|
filters: {
|
||||||
|
query: "",
|
||||||
|
status: "all",
|
||||||
|
managerId: "all",
|
||||||
|
logisticianId: "all",
|
||||||
|
messenger: "all",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.visibleOrders).toHaveLength(4);
|
||||||
|
expect(result.visibleOrders.every((order) => order.assignedDriverId === driver.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates status and prepends history record", () => {
|
||||||
|
const nextOrder = applyStatusUpdate(demoOrders[0], "Доставка согласована", "Ольга Синицына");
|
||||||
|
|
||||||
|
expect(nextOrder.status).toBe("Доставка согласована");
|
||||||
|
expect(nextOrder.history[0].action).toBe("Изменение статуса");
|
||||||
|
expect(nextOrder.history[0].newStatus).toBe("Доставка согласована");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns role-scoped transitions for logistics stage", () => {
|
||||||
|
const transitions = getAvailableTransitions({
|
||||||
|
status: "Ожидает согласования доставки",
|
||||||
|
role: "logistician",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(transitions).toEqual(["Доставка согласована", "Проблема доставки", "Отменён"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a new order draft with assigned logistician", () => {
|
||||||
|
const logisticians = demoUsers.filter((user) => user.role === "logistician");
|
||||||
|
const order = createOrderRecord({
|
||||||
|
actorName: "Анна Мельник",
|
||||||
|
availableLogisticians: logisticians,
|
||||||
|
payload: {
|
||||||
|
orderNumber: "CD-900000",
|
||||||
|
customerName: "Тест Клиент",
|
||||||
|
customerPhone: "+7 978 777-00-00",
|
||||||
|
customerAddress: "Симферополь",
|
||||||
|
messenger: "Телеграм",
|
||||||
|
managerId: "u-manager",
|
||||||
|
deliveryDate: "2026-03-15",
|
||||||
|
items: "Тестовая позиция",
|
||||||
|
comments: "важно",
|
||||||
|
tags: "новый",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(order.status).toBe("Новый");
|
||||||
|
expect(order.deliveryAgreementStatus).toBe("Не начато");
|
||||||
|
expect(order.logisticianIds).toHaveLength(1);
|
||||||
|
expect(order.history[0].action).toBe("Создание заказа");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("auto distributes orders across logisticians", () => {
|
||||||
|
const logisticians = demoUsers.filter((user) => user.role === "logistician");
|
||||||
|
const assigned = autoAssignOrders(cloneOrders(demoOrders), logisticians);
|
||||||
|
|
||||||
|
expect(assigned[0].logisticianIds[0]).toBe(logisticians[0].id);
|
||||||
|
expect(assigned[1].logisticianIds[0]).toBe(logisticians[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds dashboard metrics", () => {
|
||||||
|
const metrics = buildMetrics(demoOrders);
|
||||||
|
|
||||||
|
expect(metrics.total).toBe(7);
|
||||||
|
expect(metrics.readyToShip).toBe(1);
|
||||||
|
expect(metrics.awaitingDeliveryCoordination).toBe(1);
|
||||||
|
expect(metrics.exceptions).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,62 @@
|
||||||
|
const COMPLETED_ORDER_STATUSES = new Set(["Доставлен", "Закрыт", "Отменён"]);
|
||||||
|
const EXCEPTION_ORDER_STATUSES = new Set(["Проблема доставки"]);
|
||||||
|
|
||||||
|
const KANBAN_BASE_COLUMNS = [
|
||||||
|
{
|
||||||
|
key: "new",
|
||||||
|
title: "В работе",
|
||||||
|
statuses: [
|
||||||
|
"Новый",
|
||||||
|
"Требует уточнения",
|
||||||
|
"Подтверждён менеджером",
|
||||||
|
"В очереди производства",
|
||||||
|
"В производстве",
|
||||||
|
"Готов к отгрузке",
|
||||||
|
],
|
||||||
|
dropStatus: "В производстве",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "coordination",
|
||||||
|
title: "Согласование доставки",
|
||||||
|
statuses: ["Ожидает согласования доставки", "Доставка согласована", "Назначен водитель"],
|
||||||
|
dropStatus: "Ожидает согласования доставки",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "execution",
|
||||||
|
title: "Исполнение",
|
||||||
|
statuses: ["Загружен", "В пути"],
|
||||||
|
dropStatus: "В пути",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "exceptions",
|
||||||
|
title: "Исключения",
|
||||||
|
statuses: ["Проблема доставки"],
|
||||||
|
dropStatus: "Проблема доставки",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const COMPLETED_COLUMN = {
|
||||||
|
key: "completed",
|
||||||
|
title: "Завершённые",
|
||||||
|
statuses: [...COMPLETED_ORDER_STATUSES],
|
||||||
|
dropStatus: "Закрыт",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isCompletedOrderStatus = (status) => COMPLETED_ORDER_STATUSES.has(status);
|
||||||
|
|
||||||
|
export const isExceptionOrderStatus = (status) => EXCEPTION_ORDER_STATUSES.has(status);
|
||||||
|
|
||||||
|
export const filterRegistryOrders = (orders, { includeCompleted = false } = {}) =>
|
||||||
|
orders.filter((order) => includeCompleted || !isCompletedOrderStatus(order.status));
|
||||||
|
|
||||||
|
export const filterArchiveOrders = (orders) =>
|
||||||
|
orders.filter((order) => isCompletedOrderStatus(order.status));
|
||||||
|
|
||||||
|
export const buildKanbanColumns = (orders, { includeCompleted = false } = {}) => {
|
||||||
|
const columns = includeCompleted ? [...KANBAN_BASE_COLUMNS, COMPLETED_COLUMN] : KANBAN_BASE_COLUMNS;
|
||||||
|
|
||||||
|
return columns.map((column) => ({
|
||||||
|
...column,
|
||||||
|
items: orders.filter((order) => column.statuses.includes(order.status)),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildKanbanColumns,
|
||||||
|
filterArchiveOrders,
|
||||||
|
filterRegistryOrders,
|
||||||
|
isCompletedOrderStatus,
|
||||||
|
} from "./orderViews";
|
||||||
|
|
||||||
|
const baseOrders = [
|
||||||
|
{ id: "1", status: "Новый" },
|
||||||
|
{ id: "2", status: "Ожидает согласования доставки" },
|
||||||
|
{ id: "3", status: "Проблема доставки" },
|
||||||
|
{ id: "4", status: "Закрыт" },
|
||||||
|
{ id: "5", status: "Доставлен" },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("orderViews", () => {
|
||||||
|
it("treats delivered and closed orders as completed", () => {
|
||||||
|
expect(isCompletedOrderStatus("Доставлен")).toBe(true);
|
||||||
|
expect(isCompletedOrderStatus("Закрыт")).toBe(true);
|
||||||
|
expect(isCompletedOrderStatus("Ожидает согласования доставки")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides completed orders from registry by default", () => {
|
||||||
|
const visible = filterRegistryOrders(baseOrders, { includeCompleted: false });
|
||||||
|
|
||||||
|
expect(visible.map((order) => order.id)).toEqual(["1", "2", "3"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects completed orders in archive", () => {
|
||||||
|
const archive = filterArchiveOrders(baseOrders);
|
||||||
|
|
||||||
|
expect(archive.map((order) => order.id)).toEqual(["4", "5"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds kanban with separate exceptions and optional completed column", () => {
|
||||||
|
const withoutCompleted = buildKanbanColumns(baseOrders, { includeCompleted: false });
|
||||||
|
const withCompleted = buildKanbanColumns(baseOrders, { includeCompleted: true });
|
||||||
|
|
||||||
|
expect(withoutCompleted.map((column) => column.key)).toEqual([
|
||||||
|
"new",
|
||||||
|
"coordination",
|
||||||
|
"execution",
|
||||||
|
"exceptions",
|
||||||
|
]);
|
||||||
|
expect(withoutCompleted.find((column) => column.key === "exceptions")?.items).toHaveLength(1);
|
||||||
|
expect(withCompleted.map((column) => column.key)).toContain("completed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
export const safeSupabaseCall = async (callback, fallbackMessage = "Ошибка Supabase") => {
|
||||||
|
try {
|
||||||
|
const data = await callback();
|
||||||
|
return { data, error: null };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(fallbackMessage, error);
|
||||||
|
return { data: null, error };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { safeSupabaseCall } from "../safeSupabaseCall";
|
||||||
|
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
|
||||||
|
|
||||||
|
const CHANNEL_CODES = {
|
||||||
|
"телеграм": "telegram",
|
||||||
|
"вконтакте": "vk",
|
||||||
|
"макс": "messenger_max",
|
||||||
|
"max": "messenger_max",
|
||||||
|
"смс": "sms",
|
||||||
|
"эл. почта": "email",
|
||||||
|
"эл почта": "email",
|
||||||
|
"электронная почта": "email",
|
||||||
|
telegram: "telegram",
|
||||||
|
vk: "vk",
|
||||||
|
sms: "sms",
|
||||||
|
email: "email",
|
||||||
|
messenger_max: "messenger_max",
|
||||||
|
};
|
||||||
|
|
||||||
|
const requireSupabase = () => {
|
||||||
|
if (!hasSupabaseConfig || !supabase) {
|
||||||
|
throw new Error("Supabase не сконфигурирован");
|
||||||
|
}
|
||||||
|
return supabase;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildOrderPayload = (order) => ({
|
||||||
|
id: order.id,
|
||||||
|
order_number: order.orderNumber,
|
||||||
|
customer: order.customer,
|
||||||
|
status: order.status,
|
||||||
|
delivery_agreement_status: order.deliveryAgreementStatus || "Не начато",
|
||||||
|
manager_id: order.managerId,
|
||||||
|
logistician_id: order.logisticianIds[0] || null,
|
||||||
|
assigned_driver_id: order.assignedDriverId || null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchOrders = async () => {
|
||||||
|
return safeSupabaseCall(async () => {
|
||||||
|
const client = requireSupabase();
|
||||||
|
const { data, error } = await client
|
||||||
|
.from("orders")
|
||||||
|
.select("*, order_history(*), delivery_slots(*), chat_messages(*)")
|
||||||
|
.order("updated_at", { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}, "Ошибка загрузки заказов");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveOrder = async (order) => {
|
||||||
|
return safeSupabaseCall(async () => {
|
||||||
|
const client = requireSupabase();
|
||||||
|
const payload = buildOrderPayload(order);
|
||||||
|
|
||||||
|
const { data, error } = await client.from("orders").upsert(payload).select().single();
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}, "Ошибка сохранения заказа");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const saveChatMessage = async ({ orderId, message }) => {
|
||||||
|
return safeSupabaseCall(async () => {
|
||||||
|
const client = requireSupabase();
|
||||||
|
const normalizedChannel =
|
||||||
|
CHANNEL_CODES[message.channel.toLowerCase()] ||
|
||||||
|
message.channel.toLowerCase().replaceAll(" ", "_");
|
||||||
|
const { data, error } = await client
|
||||||
|
.from("chat_messages")
|
||||||
|
.insert({
|
||||||
|
order_id: orderId,
|
||||||
|
sender_type: message.sender,
|
||||||
|
sender_name: message.senderName || null,
|
||||||
|
channel: normalizedChannel,
|
||||||
|
text: message.text,
|
||||||
|
payload: message.payload || {},
|
||||||
|
})
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}, "Ошибка сохранения сообщения");
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { demoOrders } from "../../data/mockAppData";
|
||||||
|
import { buildOrderPayload } from "./orderRepository";
|
||||||
|
|
||||||
|
describe("orderRepository payloads", () => {
|
||||||
|
it("maps demo order fields to Supabase payload", () => {
|
||||||
|
const payload = buildOrderPayload(demoOrders[3]);
|
||||||
|
|
||||||
|
expect(payload.order_number).toBe("CD-240034");
|
||||||
|
expect(payload.status).toBe("Назначен водитель");
|
||||||
|
expect(payload.delivery_agreement_status).toBe("Подтверждено клиентом");
|
||||||
|
expect(payload.assigned_driver_id).toBe("u-driver");
|
||||||
|
expect(payload.logistician_id).toBe("u-logistics-2");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--color-base: #f4f7f5;
|
||||||
|
--color-surface: rgba(255, 255, 255, 0.78);
|
||||||
|
--color-surface-strong: #ffffff;
|
||||||
|
--color-text: #10211b;
|
||||||
|
--color-text-muted: #5d6d66;
|
||||||
|
--color-accent: #12805c;
|
||||||
|
--color-accent-soft: rgba(18, 128, 92, 0.12);
|
||||||
|
--color-accent-contrast: #f4fffb;
|
||||||
|
--color-border: rgba(16, 33, 27, 0.09);
|
||||||
|
--color-danger: #c93d3d;
|
||||||
|
--color-warning: #bf7b21;
|
||||||
|
--shadow-panel: 0 18px 48px rgba(7, 16, 13, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] {
|
||||||
|
color-scheme: dark;
|
||||||
|
--color-base: #09110f;
|
||||||
|
--color-surface: rgba(16, 26, 23, 0.82);
|
||||||
|
--color-surface-strong: #0f1b18;
|
||||||
|
--color-text: #ecf5f2;
|
||||||
|
--color-text-muted: #9eb0aa;
|
||||||
|
--color-accent: #57d8a9;
|
||||||
|
--color-accent-soft: rgba(87, 216, 169, 0.12);
|
||||||
|
--color-accent-contrast: #06120d;
|
||||||
|
--color-border: rgba(236, 245, 242, 0.09);
|
||||||
|
--color-danger: #ff8f8f;
|
||||||
|
--color-warning: #ffcf88;
|
||||||
|
--shadow-panel: 0 18px 56px rgba(0, 0, 0, 0.38);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { createClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
|
||||||
|
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
|
||||||
|
|
||||||
|
export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey);
|
||||||
|
|
||||||
|
export const supabase = hasSupabaseConfig
|
||||||
|
? createClient(supabaseUrl, supabaseAnonKey, {
|
||||||
|
auth: {
|
||||||
|
persistSession: true,
|
||||||
|
autoRefreshToken: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
export const createBrowserWindow = ({
|
||||||
|
standalone = false,
|
||||||
|
serviceWorker = undefined,
|
||||||
|
navigatorStandalone = false,
|
||||||
|
} = {}) => {
|
||||||
|
return {
|
||||||
|
matchMedia: (query) => ({
|
||||||
|
media: query,
|
||||||
|
matches: query === "(display-mode: standalone)" ? standalone : false,
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
}),
|
||||||
|
navigator: {
|
||||||
|
standalone: navigatorStandalone,
|
||||||
|
serviceWorker,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createBeforeInstallPromptEvent = ({
|
||||||
|
outcome = "accepted",
|
||||||
|
} = {}) => {
|
||||||
|
let promptCalls = 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt: async () => {
|
||||||
|
promptCalls += 1;
|
||||||
|
},
|
||||||
|
userChoice: Promise.resolve({ outcome }),
|
||||||
|
getPromptCalls: () => promptCalls,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { format } from "date-fns";
|
||||||
|
|
||||||
|
export const formatDateTime = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return "Не указано";
|
||||||
|
}
|
||||||
|
return format(new Date(value), "dd.MM.yyyy HH:mm");
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDate = (value) => {
|
||||||
|
if (!value) {
|
||||||
|
return "Не указано";
|
||||||
|
}
|
||||||
|
return format(new Date(value), "dd.MM.yyyy");
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
const logger = {
|
||||||
|
info: (message, payload) => console.info(`[info] ${message}`, payload ?? ""),
|
||||||
|
error: (message, error) => console.error(`[error] ${message}`, error ?? ""),
|
||||||
|
order: (message, payload) => console.log(`[order] ${message}`, payload ?? ""),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default logger;
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
# Edge Functions
|
||||||
|
|
||||||
|
## `chatbot-webhook`
|
||||||
|
|
||||||
|
Принимает webhook от `telegram`, `vk`, `messenger_max`, нормализует сообщение, пишет его в
|
||||||
|
`chat_messages` и при необходимости обновляет статус заказа и `order_history`.
|
||||||
|
|
||||||
|
Пример вызова:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST \
|
||||||
|
'https://<project>.supabase.co/functions/v1/chatbot-webhook?provider=telegram' \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{
|
||||||
|
"order_id": "uuid",
|
||||||
|
"text": "Подтверждаю",
|
||||||
|
"action": "confirm_delivery",
|
||||||
|
"external_message_id": "tg-42",
|
||||||
|
"payload": {"slot_id": "slot-1"}
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
## `send-chatbot-message`
|
||||||
|
|
||||||
|
Принимает исходящее сообщение, подготавливает dispatch в нужный канал и логирует отправку в
|
||||||
|
`chat_messages`.
|
||||||
|
|
||||||
|
Если передан `workflowAction=send_delivery_offer`, функция дополнительно переводит заказ в
|
||||||
|
`Ожидает согласования доставки` и выставляет `delivery_agreement_status = 'Отправлено клиенту'`.
|
||||||
|
|
||||||
|
Ожидаемые переменные:
|
||||||
|
|
||||||
|
- `SUPABASE_URL`
|
||||||
|
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||||
|
- `TELEGRAM_BOT_TOKEN`
|
||||||
|
- `VK_BOT_TOKEN`
|
||||||
|
- `MESSENGER_MAX_TOKEN`
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.8";
|
||||||
|
import { getOrderUpdateForInboundAction } from "./workflow.ts";
|
||||||
|
|
||||||
|
export type ProviderName = "telegram" | "vk" | "messenger_max";
|
||||||
|
|
||||||
|
export type NormalizedChatEvent = {
|
||||||
|
provider: ProviderName;
|
||||||
|
orderId: string;
|
||||||
|
externalMessageId: string | null;
|
||||||
|
senderType: "client" | "bot" | "system";
|
||||||
|
text: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
action: "confirm_delivery" | "reschedule" | "cancel_delivery" | "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createServiceClient = () => {
|
||||||
|
const supabaseUrl = Deno.env.get("SUPABASE_URL") || "";
|
||||||
|
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") || "";
|
||||||
|
return createClient(supabaseUrl, serviceRoleKey);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const json = (body: unknown, status = 200) =>
|
||||||
|
new Response(JSON.stringify(body), {
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const normalizeIncomingEvent = (
|
||||||
|
provider: ProviderName,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): NormalizedChatEvent => {
|
||||||
|
const payload = (body.payload as Record<string, unknown>) || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
orderId: String(body.order_id || payload.order_id || ""),
|
||||||
|
externalMessageId: body.external_message_id ? String(body.external_message_id) : null,
|
||||||
|
senderType: "client",
|
||||||
|
text: String(body.text || payload.text || ""),
|
||||||
|
payload,
|
||||||
|
action: resolveAction(body.action || payload.action),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveAction = (action: unknown): NormalizedChatEvent["action"] => {
|
||||||
|
switch (String(action || "").toLowerCase()) {
|
||||||
|
case "confirm":
|
||||||
|
case "confirm_delivery":
|
||||||
|
return "confirm_delivery";
|
||||||
|
case "reschedule":
|
||||||
|
return "reschedule";
|
||||||
|
case "cancel":
|
||||||
|
case "cancel_delivery":
|
||||||
|
return "cancel_delivery";
|
||||||
|
default:
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const orderUpdateByAction = (action: NormalizedChatEvent["action"]) =>
|
||||||
|
getOrderUpdateForInboundAction(action);
|
||||||
|
|
||||||
|
export const channelFromProvider = (provider: ProviderName) => provider;
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getOrderUpdateForInboundAction,
|
||||||
|
getOrderUpdateForOutboundDispatch,
|
||||||
|
} from "./workflow";
|
||||||
|
|
||||||
|
describe("chatbot workflow mapping", () => {
|
||||||
|
it("maps confirm delivery to agreed delivery statuses", () => {
|
||||||
|
expect(getOrderUpdateForInboundAction("confirm_delivery")).toEqual({
|
||||||
|
status: "Доставка согласована",
|
||||||
|
deliveryAgreementStatus: "Подтверждено клиентом",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps reschedule request to waiting coordination statuses", () => {
|
||||||
|
expect(getOrderUpdateForInboundAction("reschedule")).toEqual({
|
||||||
|
status: "Ожидает согласования доставки",
|
||||||
|
deliveryAgreementStatus: "Перенос запрошен",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks outbound delivery offer as sent to client", () => {
|
||||||
|
expect(getOrderUpdateForOutboundDispatch("send_delivery_offer")).toEqual({
|
||||||
|
status: "Ожидает согласования доставки",
|
||||||
|
deliveryAgreementStatus: "Отправлено клиенту",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
export type InboundWorkflowAction =
|
||||||
|
| "confirm_delivery"
|
||||||
|
| "reschedule"
|
||||||
|
| "cancel_delivery"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export type OutboundWorkflowAction =
|
||||||
|
| "send_delivery_offer"
|
||||||
|
| "send_delivery_reminder"
|
||||||
|
| "custom_message";
|
||||||
|
|
||||||
|
export const getOrderUpdateForInboundAction = (action: InboundWorkflowAction) => {
|
||||||
|
switch (action) {
|
||||||
|
case "confirm_delivery":
|
||||||
|
return {
|
||||||
|
status: "Доставка согласована",
|
||||||
|
deliveryAgreementStatus: "Подтверждено клиентом",
|
||||||
|
};
|
||||||
|
case "reschedule":
|
||||||
|
return {
|
||||||
|
status: "Ожидает согласования доставки",
|
||||||
|
deliveryAgreementStatus: "Перенос запрошен",
|
||||||
|
};
|
||||||
|
case "cancel_delivery":
|
||||||
|
return {
|
||||||
|
status: "Проблема доставки",
|
||||||
|
deliveryAgreementStatus: "Нет ответа",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getOrderUpdateForOutboundDispatch = (action: OutboundWorkflowAction) => {
|
||||||
|
switch (action) {
|
||||||
|
case "send_delivery_offer":
|
||||||
|
case "send_delivery_reminder":
|
||||||
|
return {
|
||||||
|
status: "Ожидает согласования доставки",
|
||||||
|
deliveryAgreementStatus: "Отправлено клиенту",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import {
|
||||||
|
channelFromProvider,
|
||||||
|
createServiceClient,
|
||||||
|
json,
|
||||||
|
normalizeIncomingEvent,
|
||||||
|
orderUpdateByAction,
|
||||||
|
type ProviderName,
|
||||||
|
} from "../_shared/chatbot.ts";
|
||||||
|
|
||||||
|
const corsHeaders = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.serve(async (request) => {
|
||||||
|
if (request.method === "OPTIONS") {
|
||||||
|
return new Response("ok", { headers: corsHeaders });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
const provider = url.searchParams.get("provider") as ProviderName | null;
|
||||||
|
if (!provider) {
|
||||||
|
return json({ error: "provider is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = (await request.json()) as Record<string, unknown>;
|
||||||
|
const event = normalizeIncomingEvent(provider, body);
|
||||||
|
if (!event.orderId) {
|
||||||
|
return json({ error: "order_id is required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createServiceClient();
|
||||||
|
const orderUpdate = orderUpdateByAction(event.action);
|
||||||
|
|
||||||
|
const messagePayload = {
|
||||||
|
order_id: event.orderId,
|
||||||
|
sender_name: "chatbot-webhook",
|
||||||
|
sender_type: event.senderType,
|
||||||
|
channel: channelFromProvider(event.provider),
|
||||||
|
text: event.text || `Inbound ${event.provider} event`,
|
||||||
|
external_message_id: event.externalMessageId,
|
||||||
|
payload: event.payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { error: messageError } = await supabase.from("chat_messages").insert(messagePayload);
|
||||||
|
if (messageError) {
|
||||||
|
throw messageError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (orderUpdate) {
|
||||||
|
const { data: currentOrder, error: orderError } = await supabase
|
||||||
|
.from("orders")
|
||||||
|
.select("id, status, delivery_agreement_status")
|
||||||
|
.eq("id", event.orderId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (orderError) {
|
||||||
|
throw orderError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("orders")
|
||||||
|
.update({
|
||||||
|
status: orderUpdate.status,
|
||||||
|
delivery_agreement_status: orderUpdate.deliveryAgreementStatus,
|
||||||
|
})
|
||||||
|
.eq("id", event.orderId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
throw updateError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: historyError } = await supabase.from("order_history").insert({
|
||||||
|
order_id: event.orderId,
|
||||||
|
action: `Webhook ${provider}: ${event.action}`,
|
||||||
|
old_status: currentOrder.status,
|
||||||
|
new_status: orderUpdate.status,
|
||||||
|
metadata: {
|
||||||
|
...event.payload,
|
||||||
|
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
||||||
|
new_delivery_agreement_status: orderUpdate.deliveryAgreementStatus,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (historyError) {
|
||||||
|
throw historyError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ ok: true }), {
|
||||||
|
headers: {
|
||||||
|
...corsHeaders,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unexpected error",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
...corsHeaders,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import {
|
||||||
|
channelFromProvider,
|
||||||
|
createServiceClient,
|
||||||
|
json,
|
||||||
|
type ProviderName,
|
||||||
|
} from "../_shared/chatbot.ts";
|
||||||
|
import { getOrderUpdateForOutboundDispatch, type OutboundWorkflowAction } from "../_shared/workflow.ts";
|
||||||
|
|
||||||
|
const providerTokens: Record<ProviderName, string | undefined> = {
|
||||||
|
telegram: Deno.env.get("TELEGRAM_BOT_TOKEN"),
|
||||||
|
vk: Deno.env.get("VK_BOT_TOKEN"),
|
||||||
|
messenger_max: Deno.env.get("MESSENGER_MAX_TOKEN"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendToProvider = async ({
|
||||||
|
provider,
|
||||||
|
recipientId,
|
||||||
|
text,
|
||||||
|
buttons,
|
||||||
|
}: {
|
||||||
|
provider: ProviderName;
|
||||||
|
recipientId: string;
|
||||||
|
text: string;
|
||||||
|
buttons?: Array<{ title: string; action: string }>;
|
||||||
|
}) => {
|
||||||
|
const token = providerTokens[provider];
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(`Missing token for ${provider}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
provider,
|
||||||
|
recipientId,
|
||||||
|
text,
|
||||||
|
buttons: buttons || [],
|
||||||
|
accepted: true,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
Deno.serve(async (request) => {
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return json({ error: "Method not allowed" }, 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body = (await request.json()) as {
|
||||||
|
provider: ProviderName;
|
||||||
|
orderId: string;
|
||||||
|
recipientId: string;
|
||||||
|
text: string;
|
||||||
|
buttons?: Array<{ title: string; action: string }>;
|
||||||
|
workflowAction?: OutboundWorkflowAction;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dispatchResult = await sendToProvider(body);
|
||||||
|
const supabase = createServiceClient();
|
||||||
|
|
||||||
|
const { error } = await supabase.from("chat_messages").insert({
|
||||||
|
order_id: body.orderId,
|
||||||
|
sender_name: "dispatch-function",
|
||||||
|
sender_type: "bot",
|
||||||
|
channel: channelFromProvider(body.provider),
|
||||||
|
text: body.text,
|
||||||
|
payload: {
|
||||||
|
buttons: body.buttons || [],
|
||||||
|
dispatch_result: dispatchResult,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderUpdate = getOrderUpdateForOutboundDispatch(body.workflowAction || "custom_message");
|
||||||
|
if (orderUpdate) {
|
||||||
|
const { data: currentOrder, error: orderError } = await supabase
|
||||||
|
.from("orders")
|
||||||
|
.select("id, status, delivery_agreement_status")
|
||||||
|
.eq("id", body.orderId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (orderError) {
|
||||||
|
throw orderError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("orders")
|
||||||
|
.update({
|
||||||
|
status: orderUpdate.status,
|
||||||
|
delivery_agreement_status: orderUpdate.deliveryAgreementStatus,
|
||||||
|
})
|
||||||
|
.eq("id", body.orderId);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
throw updateError;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error: historyError } = await supabase.from("order_history").insert({
|
||||||
|
order_id: body.orderId,
|
||||||
|
action: `Dispatch ${body.provider}: ${body.workflowAction || "custom_message"}`,
|
||||||
|
old_status: currentOrder.status,
|
||||||
|
new_status: orderUpdate.status,
|
||||||
|
metadata: {
|
||||||
|
old_delivery_agreement_status: currentOrder.delivery_agreement_status,
|
||||||
|
new_delivery_agreement_status: orderUpdate.deliveryAgreementStatus,
|
||||||
|
buttons: body.buttons || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (historyError) {
|
||||||
|
throw historyError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({ ok: true, dispatchResult });
|
||||||
|
} catch (error) {
|
||||||
|
return json(
|
||||||
|
{
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : "Unexpected error",
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,445 @@
|
||||||
|
create extension if not exists pgcrypto;
|
||||||
|
|
||||||
|
create table if not exists public.roles (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text not null unique,
|
||||||
|
permissions jsonb not null default '[]'::jsonb,
|
||||||
|
created_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.users (
|
||||||
|
id uuid primary key references auth.users (id) on delete cascade,
|
||||||
|
email text not null unique,
|
||||||
|
name text not null,
|
||||||
|
role_id uuid not null references public.roles (id),
|
||||||
|
last_login timestamptz,
|
||||||
|
created_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.orders (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
order_number text not null unique,
|
||||||
|
customer jsonb not null,
|
||||||
|
status text not null,
|
||||||
|
delivery_agreement_status text not null default 'Не начато',
|
||||||
|
manager_id uuid references public.users (id),
|
||||||
|
logistician_id uuid references public.users (id),
|
||||||
|
assigned_driver_id uuid references public.users (id),
|
||||||
|
created_at timestamptz not null default timezone('utc', now()),
|
||||||
|
updated_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.order_logisticians (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
order_id uuid not null references public.orders (id) on delete cascade,
|
||||||
|
logistician_id uuid not null references public.users (id) on delete cascade,
|
||||||
|
assigned_at timestamptz not null default timezone('utc', now()),
|
||||||
|
assigned_by uuid references public.users (id),
|
||||||
|
unique (order_id, logistician_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.order_history (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
order_id uuid not null references public.orders (id) on delete cascade,
|
||||||
|
action text not null,
|
||||||
|
old_status text,
|
||||||
|
new_status text,
|
||||||
|
user_id uuid references public.users (id),
|
||||||
|
metadata jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.delivery_slots (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
order_id uuid not null references public.orders (id) on delete cascade,
|
||||||
|
delivery_date date not null,
|
||||||
|
delivery_time text not null,
|
||||||
|
logistician_id uuid references public.users (id),
|
||||||
|
status text not null default 'pending_confirmation',
|
||||||
|
created_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.chat_messages (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
order_id uuid not null references public.orders (id) on delete cascade,
|
||||||
|
sender_name text,
|
||||||
|
sender_type text not null check (sender_type in ('client', 'bot', 'operator', 'system')),
|
||||||
|
channel text not null check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email')),
|
||||||
|
text text not null,
|
||||||
|
external_message_id text,
|
||||||
|
payload jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.error_logs (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
order_id uuid references public.orders (id) on delete set null,
|
||||||
|
provider text,
|
||||||
|
level text not null default 'error',
|
||||||
|
message text not null,
|
||||||
|
details jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default timezone('utc', now())
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.orders add column if not exists delivery_agreement_status text not null default 'Не начато';
|
||||||
|
alter table public.orders add column if not exists assigned_driver_id uuid references public.users (id);
|
||||||
|
alter table public.chat_messages drop constraint if exists chat_messages_channel_check;
|
||||||
|
alter table public.chat_messages
|
||||||
|
add constraint chat_messages_channel_check
|
||||||
|
check (channel in ('telegram', 'vk', 'messenger_max', 'sms', 'email'));
|
||||||
|
|
||||||
|
insert into public.roles (name, permissions)
|
||||||
|
values
|
||||||
|
(
|
||||||
|
'manager',
|
||||||
|
'["orders.create","orders.update.own","orders.read.own","comments.manage"]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'production_lead',
|
||||||
|
'["orders.read.all","production.queue.manage","orders.status.production"]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'logistician',
|
||||||
|
'["orders.read.assigned","delivery.manage","chatbots.manage"]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'driver',
|
||||||
|
'["orders.read.assigned_driver","orders.status.driver"]'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'admin',
|
||||||
|
'["*"]'::jsonb
|
||||||
|
)
|
||||||
|
on conflict (name) do nothing;
|
||||||
|
|
||||||
|
create or replace function public.set_updated_at()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
new.updated_at = timezone('utc', now());
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists orders_set_updated_at on public.orders;
|
||||||
|
create trigger orders_set_updated_at
|
||||||
|
before update on public.orders
|
||||||
|
for each row
|
||||||
|
execute function public.set_updated_at();
|
||||||
|
|
||||||
|
create or replace function public.current_role_name()
|
||||||
|
returns text
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
select r.name
|
||||||
|
from public.users u
|
||||||
|
join public.roles r on r.id = u.role_id
|
||||||
|
where u.id = auth.uid()
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create or replace function public.handle_new_user()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
set search_path = public
|
||||||
|
as $$
|
||||||
|
declare
|
||||||
|
default_role_id uuid;
|
||||||
|
begin
|
||||||
|
select id
|
||||||
|
into default_role_id
|
||||||
|
from public.roles
|
||||||
|
where name = coalesce(new.raw_user_meta_data ->> 'role', 'manager')
|
||||||
|
limit 1;
|
||||||
|
|
||||||
|
if default_role_id is null then
|
||||||
|
select id into default_role_id from public.roles where name = 'manager' limit 1;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
insert into public.users (id, email, name, role_id, last_login)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
new.email,
|
||||||
|
coalesce(new.raw_user_meta_data ->> 'name', split_part(new.email, '@', 1)),
|
||||||
|
default_role_id,
|
||||||
|
timezone('utc', now())
|
||||||
|
)
|
||||||
|
on conflict (id) do update
|
||||||
|
set email = excluded.email,
|
||||||
|
last_login = timezone('utc', now());
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists on_auth_user_created on auth.users;
|
||||||
|
create trigger on_auth_user_created
|
||||||
|
after insert on auth.users
|
||||||
|
for each row
|
||||||
|
execute function public.handle_new_user();
|
||||||
|
|
||||||
|
create or replace function public.log_order_status_change()
|
||||||
|
returns trigger
|
||||||
|
language plpgsql
|
||||||
|
security definer
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
if tg_op = 'INSERT' then
|
||||||
|
insert into public.order_history (order_id, action, old_status, new_status, user_id)
|
||||||
|
values (new.id, 'Создан заказ', null, new.status, auth.uid());
|
||||||
|
return new;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if old.status is distinct from new.status then
|
||||||
|
insert into public.order_history (order_id, action, old_status, new_status, user_id)
|
||||||
|
values (new.id, 'Изменение статуса', old.status, new.status, auth.uid());
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if old.delivery_agreement_status is distinct from new.delivery_agreement_status then
|
||||||
|
insert into public.order_history (order_id, action, old_status, new_status, user_id, metadata)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
'Изменение согласования доставки',
|
||||||
|
old.delivery_agreement_status,
|
||||||
|
new.delivery_agreement_status,
|
||||||
|
auth.uid(),
|
||||||
|
jsonb_build_object('scope', 'delivery_agreement')
|
||||||
|
);
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
drop trigger if exists orders_history_insert on public.orders;
|
||||||
|
create trigger orders_history_insert
|
||||||
|
after insert or update on public.orders
|
||||||
|
for each row
|
||||||
|
execute function public.log_order_status_change();
|
||||||
|
|
||||||
|
create index if not exists idx_users_role_id on public.users (role_id);
|
||||||
|
create index if not exists idx_orders_status on public.orders (status);
|
||||||
|
create index if not exists idx_orders_manager_id on public.orders (manager_id);
|
||||||
|
create index if not exists idx_orders_logistician_id on public.orders (logistician_id);
|
||||||
|
create index if not exists idx_orders_assigned_driver_id on public.orders (assigned_driver_id);
|
||||||
|
create index if not exists idx_orders_created_at on public.orders (created_at desc);
|
||||||
|
create index if not exists idx_order_logisticians_order_id on public.order_logisticians (order_id);
|
||||||
|
create index if not exists idx_order_logisticians_logistician_id on public.order_logisticians (logistician_id);
|
||||||
|
create index if not exists idx_order_history_order_id on public.order_history (order_id, created_at desc);
|
||||||
|
create index if not exists idx_delivery_slots_order_id on public.delivery_slots (order_id);
|
||||||
|
create index if not exists idx_delivery_slots_logistician_id on public.delivery_slots (logistician_id);
|
||||||
|
create index if not exists idx_chat_messages_order_id on public.chat_messages (order_id, created_at desc);
|
||||||
|
create index if not exists idx_chat_messages_external_message_id on public.chat_messages (external_message_id);
|
||||||
|
create unique index if not exists idx_chat_messages_channel_external_unique
|
||||||
|
on public.chat_messages (channel, external_message_id)
|
||||||
|
where external_message_id is not null;
|
||||||
|
create index if not exists idx_orders_search on public.orders using gin (
|
||||||
|
to_tsvector(
|
||||||
|
'simple',
|
||||||
|
coalesce(order_number, '') || ' ' || coalesce(customer ->> 'name', '') || ' ' || coalesce(customer ->> 'phone', '')
|
||||||
|
)
|
||||||
|
);
|
||||||
|
create index if not exists idx_chat_messages_search on public.chat_messages using gin (
|
||||||
|
to_tsvector('russian', coalesce(text, ''))
|
||||||
|
);
|
||||||
|
|
||||||
|
alter table public.roles enable row level security;
|
||||||
|
alter table public.users enable row level security;
|
||||||
|
alter table public.orders enable row level security;
|
||||||
|
alter table public.order_logisticians enable row level security;
|
||||||
|
alter table public.order_history enable row level security;
|
||||||
|
alter table public.delivery_slots enable row level security;
|
||||||
|
alter table public.chat_messages enable row level security;
|
||||||
|
alter table public.error_logs enable row level security;
|
||||||
|
|
||||||
|
drop policy if exists "roles admin only" on public.roles;
|
||||||
|
create policy "roles admin only" on public.roles
|
||||||
|
for all
|
||||||
|
using (public.current_role_name() = 'admin')
|
||||||
|
with check (public.current_role_name() = 'admin');
|
||||||
|
|
||||||
|
drop policy if exists "users self or admin" on public.users;
|
||||||
|
create policy "users self or admin" on public.users
|
||||||
|
for select
|
||||||
|
using (id = auth.uid() or public.current_role_name() = 'admin');
|
||||||
|
|
||||||
|
drop policy if exists "users admin update" on public.users;
|
||||||
|
create policy "users admin update" on public.users
|
||||||
|
for all
|
||||||
|
using (public.current_role_name() = 'admin')
|
||||||
|
with check (public.current_role_name() = 'admin');
|
||||||
|
|
||||||
|
drop policy if exists "orders select by role" on public.orders;
|
||||||
|
create policy "orders select by role" on public.orders
|
||||||
|
for select
|
||||||
|
using (
|
||||||
|
public.current_role_name() = 'admin'
|
||||||
|
or public.current_role_name() = 'production_lead'
|
||||||
|
or (public.current_role_name() = 'manager' and manager_id = auth.uid())
|
||||||
|
or (public.current_role_name() = 'driver' and assigned_driver_id = auth.uid())
|
||||||
|
or (
|
||||||
|
public.current_role_name() = 'logistician'
|
||||||
|
and (
|
||||||
|
logistician_id = auth.uid()
|
||||||
|
or exists (
|
||||||
|
select 1
|
||||||
|
from public.order_logisticians ol
|
||||||
|
where ol.order_id = orders.id and ol.logistician_id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists "orders insert managers admin" on public.orders;
|
||||||
|
create policy "orders insert managers admin" on public.orders
|
||||||
|
for insert
|
||||||
|
with check (public.current_role_name() in ('manager', 'admin'));
|
||||||
|
|
||||||
|
drop policy if exists "orders update by workflow role" on public.orders;
|
||||||
|
create policy "orders update by workflow role" on public.orders
|
||||||
|
for update
|
||||||
|
using (
|
||||||
|
public.current_role_name() = 'admin'
|
||||||
|
or (public.current_role_name() = 'manager' and manager_id = auth.uid())
|
||||||
|
or (public.current_role_name() = 'driver' and assigned_driver_id = auth.uid())
|
||||||
|
or public.current_role_name() = 'production_lead'
|
||||||
|
or (
|
||||||
|
public.current_role_name() = 'logistician'
|
||||||
|
and (
|
||||||
|
logistician_id = auth.uid()
|
||||||
|
or exists (
|
||||||
|
select 1
|
||||||
|
from public.order_logisticians ol
|
||||||
|
where ol.order_id = orders.id and ol.logistician_id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with check (
|
||||||
|
public.current_role_name() = 'admin'
|
||||||
|
or (public.current_role_name() = 'manager' and manager_id = auth.uid())
|
||||||
|
or (public.current_role_name() = 'driver' and assigned_driver_id = auth.uid())
|
||||||
|
or public.current_role_name() = 'production_lead'
|
||||||
|
or (
|
||||||
|
public.current_role_name() = 'logistician'
|
||||||
|
and (
|
||||||
|
logistician_id = auth.uid()
|
||||||
|
or exists (
|
||||||
|
select 1
|
||||||
|
from public.order_logisticians ol
|
||||||
|
where ol.order_id = orders.id and ol.logistician_id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists "history select by order role" on public.order_history;
|
||||||
|
create policy "history select by order role" on public.order_history
|
||||||
|
for select
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.orders o
|
||||||
|
where o.id = order_history.order_id
|
||||||
|
and (
|
||||||
|
public.current_role_name() = 'admin'
|
||||||
|
or public.current_role_name() = 'production_lead'
|
||||||
|
or (public.current_role_name() = 'manager' and o.manager_id = auth.uid())
|
||||||
|
or (public.current_role_name() = 'driver' and o.assigned_driver_id = auth.uid())
|
||||||
|
or (
|
||||||
|
public.current_role_name() = 'logistician'
|
||||||
|
and (
|
||||||
|
o.logistician_id = auth.uid()
|
||||||
|
or exists (
|
||||||
|
select 1
|
||||||
|
from public.order_logisticians ol
|
||||||
|
where ol.order_id = o.id and ol.logistician_id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists "history insert workflow" on public.order_history;
|
||||||
|
create policy "history insert workflow" on public.order_history
|
||||||
|
for insert
|
||||||
|
with check (public.current_role_name() in ('manager', 'production_lead', 'logistician', 'driver', 'admin'));
|
||||||
|
|
||||||
|
drop policy if exists "slots by order role" on public.delivery_slots;
|
||||||
|
create policy "slots by order role" on public.delivery_slots
|
||||||
|
for all
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.orders o
|
||||||
|
where o.id = delivery_slots.order_id
|
||||||
|
and (
|
||||||
|
public.current_role_name() = 'admin'
|
||||||
|
or public.current_role_name() = 'production_lead'
|
||||||
|
or (public.current_role_name() = 'manager' and o.manager_id = auth.uid())
|
||||||
|
or (public.current_role_name() = 'driver' and o.assigned_driver_id = auth.uid())
|
||||||
|
or (
|
||||||
|
public.current_role_name() = 'logistician'
|
||||||
|
and (
|
||||||
|
o.logistician_id = auth.uid()
|
||||||
|
or exists (
|
||||||
|
select 1
|
||||||
|
from public.order_logisticians ol
|
||||||
|
where ol.order_id = o.id and ol.logistician_id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with check (public.current_role_name() in ('logistician', 'admin'));
|
||||||
|
|
||||||
|
drop policy if exists "chat by order role" on public.chat_messages;
|
||||||
|
create policy "chat by order role" on public.chat_messages
|
||||||
|
for select
|
||||||
|
using (
|
||||||
|
exists (
|
||||||
|
select 1
|
||||||
|
from public.orders o
|
||||||
|
where o.id = chat_messages.order_id
|
||||||
|
and (
|
||||||
|
public.current_role_name() = 'admin'
|
||||||
|
or public.current_role_name() = 'production_lead'
|
||||||
|
or (public.current_role_name() = 'manager' and o.manager_id = auth.uid())
|
||||||
|
or (public.current_role_name() = 'driver' and o.assigned_driver_id = auth.uid())
|
||||||
|
or (
|
||||||
|
public.current_role_name() = 'logistician'
|
||||||
|
and (
|
||||||
|
o.logistician_id = auth.uid()
|
||||||
|
or exists (
|
||||||
|
select 1
|
||||||
|
from public.order_logisticians ol
|
||||||
|
where ol.order_id = o.id and ol.logistician_id = auth.uid()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop policy if exists "chat insert workflow" on public.chat_messages;
|
||||||
|
create policy "chat insert workflow" on public.chat_messages
|
||||||
|
for insert
|
||||||
|
with check (public.current_role_name() in ('manager', 'logistician', 'admin'));
|
||||||
|
|
||||||
|
drop policy if exists "order logisticians by role" on public.order_logisticians;
|
||||||
|
create policy "order logisticians by role" on public.order_logisticians
|
||||||
|
for all
|
||||||
|
using (public.current_role_name() in ('logistician', 'admin'))
|
||||||
|
with check (public.current_role_name() in ('logistician', 'admin'));
|
||||||
|
|
||||||
|
drop policy if exists "error logs admin only" on public.error_logs;
|
||||||
|
create policy "error logs admin only" on public.error_logs
|
||||||
|
for all
|
||||||
|
using (public.current_role_name() = 'admin')
|
||||||
|
with check (public.current_role_name() = 'admin');
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./index.html", "./src/**/*.{js,jsx}"],
|
||||||
|
darkMode: ["class", '[data-theme="dark"]'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
surface: "var(--color-surface)",
|
||||||
|
base: "var(--color-base)",
|
||||||
|
text: "var(--color-text)",
|
||||||
|
muted: "var(--color-text-muted)",
|
||||||
|
accent: "var(--color-accent)",
|
||||||
|
border: "var(--color-border)",
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
soft: "0 18px 48px rgba(15, 23, 42, 0.12)",
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
panel: "28px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
react: ["react", "react-dom", "react-router-dom"],
|
||||||
|
supabase: ["@supabase/supabase-js"],
|
||||||
|
motion: ["framer-motion"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue