Initial import

This commit is contained in:
Codex 2026-03-14 18:40:54 +03:00
commit b40a4a553e
88 changed files with 14224 additions and 0 deletions

2
.env.example Normal file
View File

@ -0,0 +1,2 @@
VITE_SUPABASE_URL=https://your-project.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules
dist
.env
.env.local
.DS_Store
.superpowers

34
README.md Normal file
View File

@ -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` — сценарии жизненного цикла заказа.

46
docs/architecture.md Normal file
View File

@ -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-режим использовали одну процессную модель.

View File

@ -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 событий.

45
docs/scenarios.md Normal file
View File

@ -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. В истории появляется финальная запись, а чат закрывается для активных действий.

View File

@ -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-режиме и что ещё осталось вне объёма.

View File

@ -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`.
- [ ] Зафиксировать, что именно стало проще для заказчика в демонстрации.

View File

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

View File

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

View File

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

48
eslint.config.js Normal file
View File

@ -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",
},
},
},
];

21
index.html Normal file
View File

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

6425
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
package.json Normal file
View File

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

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View File

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

View File

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

View File

@ -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"
}
]
}

75
public/service-worker.js Normal file
View File

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

8
src/App.jsx Normal file
View File

@ -0,0 +1,8 @@
import React from "react";
import { Outlet } from "react-router-dom";
const App = () => {
return <Outlet />;
};
export default App;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("Демо-данные доступны локально");
});
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1 @@
export { ORDER_STATUSES } from "./deliveryWorkflow";

35
src/constants/roles.js Normal file
View File

@ -0,0 +1,35 @@
export const ROLE_LABELS = {
manager: "Менеджер",
production_lead: "Начальник производства",
logistician: "Логист",
driver: "Водитель",
admin: "Администратор",
};
export const ROLE_PERMISSIONS = {
manager: [
"Создание и редактирование заказов",
"Поиск и фильтрация по заказам",
"Комментарии и контроль подтверждения",
],
production_lead: [
"Очередь производства",
"Изменение статусов производства",
"Контроль готовности к доставке",
],
logistician: [
"Согласование доставки с клиентом",
"Назначение водителя и рейса",
"Разбор проблемных доставок",
],
driver: [
"Просмотр назначенных доставок",
"Подтверждение загрузки и выезда",
"Фиксация результата доставки",
],
admin: [
"Полный доступ к заказам",
"Управление пользователями и ролями",
"Логи, ошибки и история действий",
],
};

168
src/context/AuthContext.jsx Normal file
View File

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

View File

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

View File

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

650
src/data/mockAppData.js Normal file
View File

@ -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: производство завершено, можно запускать согласование доставки.",
},
];

252
src/hooks/useOrders.js Normal file
View File

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

163
src/hooks/usePwaStatus.js Normal file
View File

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

View File

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

42
src/index.css Normal file
View File

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

83
src/layouts/AppShell.jsx Normal file
View File

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

4
src/lib/cn.js Normal file
View File

@ -0,0 +1,4 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs) => twMerge(clsx(inputs));

20
src/main.jsx Normal file
View File

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

907
src/pages/DashboardPage.jsx Normal file
View File

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

59
src/pages/LoginPage.jsx Normal file
View File

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

View File

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

31
src/router.jsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;
}, "Ошибка сохранения сообщения");
};

View File

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

View File

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

15
src/supabaseClient.js Normal file
View File

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

32
src/test/pwaTestUtils.js Normal file
View File

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

15
src/utils/formatters.js Normal file
View File

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

7
src/utils/logger.js Normal file
View File

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

View File

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

View File

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

View File

@ -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: "Отправлено клиенту",
});
});
});

View File

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

View File

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

View File

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

445
supabase/schema.sql Normal file
View File

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

24
tailwind.config.js Normal file
View File

@ -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: [],
};

17
vite.config.js Normal file
View File

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