Polish demo UI and delivery filters
This commit is contained in:
parent
72673e01fc
commit
5dcfa80940
|
|
@ -16,7 +16,6 @@ npm run dev
|
||||||
## Что уже есть
|
## Что уже есть
|
||||||
|
|
||||||
- OTP-вход по email через Supabase Auth.
|
- OTP-вход по email через Supabase Auth.
|
||||||
- Служебный вход `roles@local` для демонстрации ролей менеджера, логиста и водителя.
|
|
||||||
- Role-based dashboard для менеджера, логиста и водителя.
|
- Role-based dashboard для менеджера, логиста и водителя.
|
||||||
- Карточка заказа с составом, комментариями и историей.
|
- Карточка заказа с составом, комментариями и историей.
|
||||||
- Публичная страница `/delivery/:token` для выбора даты, половины дня и просмотра состава заказа.
|
- Публичная страница `/delivery/:token` для выбора даты, половины дня и просмотра состава заказа.
|
||||||
|
|
@ -28,7 +27,7 @@ npm run dev
|
||||||
- `src/` — интерфейс и клиентская логика.
|
- `src/` — интерфейс и клиентская логика.
|
||||||
- `supabase/schema.sql` — структура БД, роли, индексы, RLS, триггеры.
|
- `supabase/schema.sql` — структура БД, роли, индексы, RLS, триггеры.
|
||||||
- `supabase/functions/` — Edge Functions для приглашений, статусов и чат-коммуникаций.
|
- `supabase/functions/` — Edge Functions для приглашений, статусов и чат-коммуникаций.
|
||||||
- `supabase/seed/stage-1-demo.sql` — рабочий набор seed-данных для показа.
|
- `supabase/seed/stage-1-demo.sql` — набор seed-данных для показа заказчику.
|
||||||
- `docs/architecture.md` — архитектура фронтенда и модулей.
|
- `docs/architecture.md` — архитектура фронтенда и модулей.
|
||||||
- `docs/product-overview.md` — общий обзор продукта, ролей и сценариев.
|
- `docs/product-overview.md` — общий обзор продукта, ролей и сценариев.
|
||||||
- `docs/scenarios.md` — сценарии жизненного цикла заказа.
|
- `docs/scenarios.md` — сценарии жизненного цикла заказа.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,299 @@
|
||||||
|
# План подготовки приложения к демонстрации заказчику
|
||||||
|
|
||||||
|
Дата проверки: 2026-04-27
|
||||||
|
Цель: привести приложение к виду полноценного рабочего продукта перед показом заказчику, без упоминаний тестового, демо, локального, боевого или служебного режима в пользовательском интерфейсе.
|
||||||
|
|
||||||
|
## Краткий вывод
|
||||||
|
|
||||||
|
Приложение частично соответствует последнему продуктовому заданию: есть OTP-вход, роль-ориентированный кабинет, публичная клиентская страница выбора доставки, модель delivery sets, Supabase schema и Edge Functions. Тесты проходят, production build собирается.
|
||||||
|
|
||||||
|
Главная проблема перед показом: в UI и публичных метаданных остались служебные и демонстрационные следы. Также логистический кабинет сейчас не использует готовую доску `LogisticsReadinessBoard`, хотя она описана в ТЗ и уже реализована как отдельный компонент. Из-за этого сценарий логиста в приложении не совпадает с документированным demo/customer flow.
|
||||||
|
|
||||||
|
## Что проверено
|
||||||
|
|
||||||
|
- `docs/product-overview.md`
|
||||||
|
- `docs/scenarios.md`
|
||||||
|
- `docs/superpowers/specs/2026-04-13-1c-delivery-ui-design.md`
|
||||||
|
- `docs/superpowers/plans/2026-04-13-1c-delivery-frontend-supabase.md`
|
||||||
|
- `src/pages/LoginPage.jsx`
|
||||||
|
- `src/components/auth/OtpLoginForm.jsx`
|
||||||
|
- `src/context/AuthContext.jsx`
|
||||||
|
- `src/pages/DashboardPage.jsx`
|
||||||
|
- `src/hooks/useOrders.js`
|
||||||
|
- `src/components/dashboard/RoleWorkspacePanel.jsx`
|
||||||
|
- `src/components/logistics/LogisticsReadinessBoard.jsx`
|
||||||
|
- `src/components/logistics/DeliverySetDetailPanel.jsx`
|
||||||
|
- `src/pages/ClientDeliveryPage.jsx`
|
||||||
|
- `src/components/client/DeliveryChoiceFlow.jsx`
|
||||||
|
- `src/components/client/DeliverySlotsPicker.jsx`
|
||||||
|
- `src/components/driver/DriverDeliveryPlanner.jsx`
|
||||||
|
- `src/components/driver/DriverDeliveryDetail.jsx`
|
||||||
|
- `public/manifest.webmanifest`
|
||||||
|
- `README.md`
|
||||||
|
|
||||||
|
## Проверка команд
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат: проходит, 71 test file, 305 tests.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат: проходит, Vite production build собран.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Результат: падает. Основные проблемы:
|
||||||
|
|
||||||
|
- `src/pages/DashboardPage.jsx`: `React.useMemo` вызывается после условного `return`, нарушение `react-hooks/rules-of-hooks`.
|
||||||
|
- `src/services/deliveryInvitationApi.js`: неиспользуемые переменные `error` в `catch`.
|
||||||
|
- `.worktrees/role-focus-dashboard-slice/...`: ESLint также проверяет рабочие worktree-копии и находит там ошибки. Нужно либо исключить `.worktrees` из lint, либо привести эти worktree в порядок/удалить их, если они не нужны.
|
||||||
|
|
||||||
|
## Соответствие ТЗ
|
||||||
|
|
||||||
|
| Блок | Ожидание по ТЗ | Текущее состояние | Статус |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Вход оператора | Email + одноразовый код, без служебных подсказок | Есть OTP, но видны служебный/локальный вход, выбор роли, `roles@local`, `000000`, "Рабочий режим" | Не соответствует для показа |
|
||||||
|
| Роли | Менеджер, логист, водитель, админ в рабочем контуре | Роли есть, но сохраняются legacy-следы `production_lead` и производственный контур | Частично |
|
||||||
|
| Логист | `LogisticsReadinessBoard` с наборами доставки | Компонент есть, но `DashboardPage` показывает старые колонки "Сегодня/Завтра/Послезавтра" по отдельным заказам | Не соответствует |
|
||||||
|
| Delivery sets | Набор доставки как основная единица | Хелперы и компоненты есть, но основной логистический экран их не использует | Частично |
|
||||||
|
| Клиентская ссылка | `/delivery/:token`, выбор даты и половины дня | Реализовано, flow выглядит близко к ТЗ | В целом соответствует |
|
||||||
|
| Водитель | Назначенные доставки, адрес, состав, быстрые статусы | Реализовано | В целом соответствует |
|
||||||
|
| Supabase | Live data, invitations, slots, history | Схема и API есть, но UI показывает "Данные загружены из Supabase, живой контур активен" | Технически есть, copy требует полировки |
|
||||||
|
| Публичные тексты | Не должно быть "демо", "тест", "локальный", "боевой", "рабочий режим" | Такие тексты найдены в login UI, manifest, README/scenarios | Не соответствует |
|
||||||
|
|
||||||
|
## Найденные замечания
|
||||||
|
|
||||||
|
### P0. Убрать служебный вход и режимные надписи из экрана входа
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
|
||||||
|
- `src/components/auth/OtpLoginForm.jsx`
|
||||||
|
- `src/pages/LoginPage.jsx`
|
||||||
|
- `src/context/AuthContext.jsx`
|
||||||
|
- `src/components/auth/OtpLoginForm.test.jsx`
|
||||||
|
- `src/context/AuthContext.test.js`
|
||||||
|
|
||||||
|
Что сейчас видно пользователю:
|
||||||
|
|
||||||
|
- "Для быстрого доступа к рабочим кабинетам можно использовать служебный адрес и выбрать роль вручную."
|
||||||
|
- "Служебный вход"
|
||||||
|
- "Локальный вход"
|
||||||
|
- "Роль для быстрого входа"
|
||||||
|
- "Войти без кода"
|
||||||
|
- "Локальный вход использует единый адрес и код 000000."
|
||||||
|
- "Локальный режим позволяет открыть интерфейс..."
|
||||||
|
- "Рабочий режим: код отправляется на email..."
|
||||||
|
|
||||||
|
Что нужно сделать:
|
||||||
|
|
||||||
|
- Оставить только обычный сценарий: email -> отправка кода -> ввод кода -> вход.
|
||||||
|
- Убрать из UI выбор роли на входе.
|
||||||
|
- Убрать публичное поведение `roles@local`.
|
||||||
|
- Убрать публичный текст про `000000`.
|
||||||
|
- Сохранить локальный fallback только как dev-only механизм, если он ещё нужен разработке, но не показывать его в интерфейсе.
|
||||||
|
- Переписать intro copy: "Введите email, мы отправим одноразовый код для входа."
|
||||||
|
- После отправки кода оставить нейтральное сообщение: "Код отправлен на указанную почту. Проверьте входящие и папку Спам."
|
||||||
|
- Обновить тесты, чтобы они проверяли отсутствие служебного/локального/рабочего режима.
|
||||||
|
|
||||||
|
Критерий готовности:
|
||||||
|
|
||||||
|
- На `/login` нет слов: `служебный`, `локальный`, `рабочий режим`, `демо`, `test`, `roles@local`, `local@local`, `000000`, `без кода`.
|
||||||
|
|
||||||
|
### P0. Подключить реальный логистический экран delivery sets
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
|
||||||
|
- `src/pages/DashboardPage.jsx`
|
||||||
|
- `src/hooks/useOrders.js`
|
||||||
|
- `src/components/dashboard/RoleWorkspacePanel.jsx`
|
||||||
|
- `src/components/logistics/LogisticsReadinessBoard.jsx`
|
||||||
|
- `src/components/logistics/DeliverySetDetailPanel.jsx`
|
||||||
|
- `src/pages/DashboardPage.test.jsx`
|
||||||
|
|
||||||
|
Что сейчас не совпадает:
|
||||||
|
|
||||||
|
- ТЗ и `docs/scenarios.md` обещают логисту `LogisticsReadinessBoard`.
|
||||||
|
- В `DashboardPage.jsx` компонент импортирован не используется.
|
||||||
|
- Логист видит старый экран с колонками "Сегодня", "Завтра", "Послезавтра" по отдельным заказам.
|
||||||
|
|
||||||
|
Что нужно сделать:
|
||||||
|
|
||||||
|
- Передать `deliverySetBuckets` из `useOrders()` в `RoleWorkspacePanel`.
|
||||||
|
- Заменить старый `renderLogisticsWorkspace` на `LogisticsReadinessBoard`.
|
||||||
|
- При клике по набору открывать `DeliverySetDetailPanel`.
|
||||||
|
- Сохранить понятную карточку заказа там, где она нужна менеджеру.
|
||||||
|
- Добавить/обновить тест на то, что логист видит "Наборы доставки", "На подходе", "Готово к запуску", "Ожидает клиента", "Нужна ручная работа", "Согласовано", "Завершено".
|
||||||
|
|
||||||
|
Критерий готовности:
|
||||||
|
|
||||||
|
- Логистический сценарий в приложении совпадает с `docs/scenarios.md`.
|
||||||
|
- Логист работает с наборами доставки, а не с отдельными заказами как главной единицей.
|
||||||
|
|
||||||
|
### P1. Убрать технические баннеры Supabase/live contour из UI
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
|
||||||
|
- `src/pages/DashboardPage.jsx`
|
||||||
|
- `src/hooks/useOrders.js`
|
||||||
|
|
||||||
|
Что сейчас видно:
|
||||||
|
|
||||||
|
- "Загружаем данные из Supabase..."
|
||||||
|
- "Данные загружены из Supabase, живой контур активен."
|
||||||
|
- Ошибки могут показывать "Supabase" как техническую деталь.
|
||||||
|
|
||||||
|
Что нужно сделать:
|
||||||
|
|
||||||
|
- Заменить на пользовательские формулировки:
|
||||||
|
- "Загружаем данные..."
|
||||||
|
- Баннер успешной загрузки убрать полностью.
|
||||||
|
- Ошибки показывать как "Не удалось загрузить данные. Обратитесь к администратору."
|
||||||
|
- Технические детали Supabase оставлять только в логах.
|
||||||
|
|
||||||
|
Критерий готовности:
|
||||||
|
|
||||||
|
- В рабочем UI нет слова `Supabase`, кроме внутренних логов/документации для команды.
|
||||||
|
|
||||||
|
### P1. Очистить публичные метаданные и demo wording
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
|
||||||
|
- `public/manifest.webmanifest`
|
||||||
|
- `README.md`
|
||||||
|
- `docs/scenarios.md`
|
||||||
|
- `docs/product-overview.md`
|
||||||
|
|
||||||
|
Что сейчас найдено:
|
||||||
|
|
||||||
|
- `public/manifest.webmanifest`: "PWA-демо панели заказов и доставки..."
|
||||||
|
- `README.md`: "Служебный вход `roles@local` для демонстрации ролей..."
|
||||||
|
- `docs/scenarios.md`: "Demo-скрипт для первого платного milestone"
|
||||||
|
|
||||||
|
Что нужно сделать:
|
||||||
|
|
||||||
|
- В manifest заменить описание на рабочее: "Панель управления доставкой заказов с доступом к кабинетам логиста, водителя и менеджера."
|
||||||
|
- В README убрать служебный вход из публичного списка "Что уже есть".
|
||||||
|
- В `docs/scenarios.md` переименовать demo-скрипт в "Сценарий показа заказчику".
|
||||||
|
- Убрать из пользовательской/презентационной документации `roles@local`, `demo`, `тестовый`, `локальный`, если это не инструкция строго для разработчика.
|
||||||
|
|
||||||
|
Критерий готовности:
|
||||||
|
|
||||||
|
- Поиск по публичным файлам не находит демонстрационные маркеры в пользовательском контексте.
|
||||||
|
|
||||||
|
### P1. Привести lint в зелёное состояние
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
|
||||||
|
- `src/pages/DashboardPage.jsx`
|
||||||
|
- `src/services/deliveryInvitationApi.js`
|
||||||
|
- `eslint.config.js` или `.eslintignore`/аналогичная настройка
|
||||||
|
- `.worktrees/`, если они остаются в репозитории/рабочей папке
|
||||||
|
|
||||||
|
Что нужно сделать:
|
||||||
|
|
||||||
|
- Перенести `useMemo` в `DashboardPage.jsx` выше раннего `return`, чтобы hooks всегда вызывались в одном порядке.
|
||||||
|
- В `deliveryInvitationApi.js` заменить `catch (error)` на `catch` там, где переменная не используется.
|
||||||
|
- Исключить `.worktrees/**` из lint, если эти директории не являются частью основного приложения.
|
||||||
|
- Повторно запустить `npm run lint`.
|
||||||
|
|
||||||
|
Критерий готовности:
|
||||||
|
|
||||||
|
- `npm run lint` проходит без ошибок.
|
||||||
|
|
||||||
|
### P2. Убрать устаревшие производственные экраны из маршрута показа
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
|
||||||
|
- `src/constants/roles.js`
|
||||||
|
- `src/constants/deliveryWorkflow.js`
|
||||||
|
- `src/components/dashboard/ProductionQueuePanel.jsx`
|
||||||
|
- `src/components/orders/OrderEditorPanel.jsx`
|
||||||
|
- `src/services/orderService.js`
|
||||||
|
- `src/data/mockAppData.js`
|
||||||
|
|
||||||
|
Что сейчас может сбивать:
|
||||||
|
|
||||||
|
- `production_lead`, "Начальник производства", "Очередь производства".
|
||||||
|
- `OrderEditorPanel` всё ещё выглядит как форма ручного создания/редактирования заказа.
|
||||||
|
- `createOrder` и уведомление "Новый заказ" остаются в hook, хотя ТЗ говорит, что заказы приходят из 1С.
|
||||||
|
|
||||||
|
Что нужно сделать:
|
||||||
|
|
||||||
|
- Проверить, доступны ли эти экраны из текущего UI. Если недоступны, оставить как legacy code с задачей на последующую чистку.
|
||||||
|
- Если доступны, убрать их из демонстрационного маршрута.
|
||||||
|
- Для менеджера оставить только просмотр реестра, поиск и карточку заказа.
|
||||||
|
- Не показывать создание заказа как пользовательскую возможность.
|
||||||
|
|
||||||
|
Критерий готовности:
|
||||||
|
|
||||||
|
- На показе нет сценария ручного создания заказа в web app.
|
||||||
|
- Заказ в интерфейсе воспринимается как импортированный из 1С.
|
||||||
|
|
||||||
|
### P2. Финальная визуальная проверка всех ролей
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
|
||||||
|
- UI-компоненты по факту найденных визуальных замечаний.
|
||||||
|
|
||||||
|
Что нужно сделать:
|
||||||
|
|
||||||
|
- Открыть `/login`.
|
||||||
|
- Проверить вход оператора.
|
||||||
|
- Проверить кабинет менеджера.
|
||||||
|
- Проверить кабинет логиста.
|
||||||
|
- Проверить кабинет водителя.
|
||||||
|
- Проверить `/delivery/client-flow-1001`.
|
||||||
|
- Проверить мобильную ширину для `/login`, `/dashboard`, `/delivery/client-flow-1001`.
|
||||||
|
|
||||||
|
Чеклист:
|
||||||
|
|
||||||
|
- Нет служебных/локальных/demo/test/боевых/рабочих режимов.
|
||||||
|
- Нет технических баннеров про Supabase/live contour.
|
||||||
|
- Тексты звучат как рабочий продукт, а не как стенд.
|
||||||
|
- Логист видит delivery sets.
|
||||||
|
- Клиентская страница понятна без объяснений команды.
|
||||||
|
- Водительский экран показывает только назначенные доставки и действия маршрута.
|
||||||
|
|
||||||
|
## Рекомендуемый порядок работ
|
||||||
|
|
||||||
|
1. Исправить экран входа и auth-copy.
|
||||||
|
2. Подключить `LogisticsReadinessBoard` в `DashboardPage`.
|
||||||
|
3. Убрать технические баннеры и режимные тексты из dashboard.
|
||||||
|
4. Очистить manifest/README/scenarios от demo wording.
|
||||||
|
5. Починить lint.
|
||||||
|
6. Пройти ручной smoke test по ролям.
|
||||||
|
7. Подготовить короткий сценарий показа заказчику.
|
||||||
|
|
||||||
|
## Команды финальной проверки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
rg -n "(demo|демо|test|тест|боев|рабочий режим|локальный|служебный|roles@local|local@local|000000|без кода|живой контур|Supabase)" src public README.md docs/product-overview.md docs/scenarios.md -S
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидание:
|
||||||
|
|
||||||
|
- `npm test` проходит.
|
||||||
|
- `npm run lint` проходит.
|
||||||
|
- `npm run build` проходит.
|
||||||
|
- `rg` не находит запрещённые слова в пользовательском UI и публичных материалах. Допустимы только внутренние тесты, dev-only комментарии и техническая документация, не используемая в демонстрации.
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
|
||||||
|
- Экран входа выглядит как production-ready вход по email-коду.
|
||||||
|
- Все режимные и служебные подсказки убраны из UI.
|
||||||
|
- Логистический кабинет работает по delivery sets.
|
||||||
|
- Клиентская ссылка работает и не содержит технических пояснений.
|
||||||
|
- Водительский кабинет показывает назначенные доставки и статусы маршрута.
|
||||||
|
- Публичные метаданные приложения не называют продукт демо.
|
||||||
|
- Тесты, lint и build зелёные.
|
||||||
|
- Есть отдельный сценарий показа заказчику без внутренних технических деталей.
|
||||||
|
|
@ -10,14 +10,14 @@
|
||||||
## 2. Логист открывает рабочее пространство доставки
|
## 2. Логист открывает рабочее пространство доставки
|
||||||
|
|
||||||
1. Логист видит **LogisticsReadinessBoard** — доску с наборами доставки, сгруппированными по статусам:
|
1. Логист видит **LogisticsReadinessBoard** — доску с наборами доставки, сгруппированными по статусам:
|
||||||
- **На подходе**: не все заказы набора приняты ОТК.
|
- **На подходе**: не все заказы набора прошли контроль качества.
|
||||||
- **Готово к запуску**: все заказы приняты, можно запускать доставку.
|
- **Готово к запуску**: все заказы приняты, можно запускать доставку.
|
||||||
- **Ожидает клиента**: отправлено приглашение, ждём ответа.
|
- **Ожидает клиента**: отправлено приглашение, ждём ответа.
|
||||||
- **Нужна ручная работа**: передано логисту, платное хранение, проблема.
|
- **Нужна ручная работа**: передано логисту, платное хранение, проблема.
|
||||||
- **Согласовано**: клиент подтвердил слот.
|
- **Согласовано**: клиент подтвердил слот.
|
||||||
- **Завершено**: все заказы доставлены.
|
- **Завершено**: все заказы доставлены.
|
||||||
2. Клик по набору открывает **DeliverySetDetailPanel** с:
|
2. Клик по набору открывает **DeliverySetDetailPanel** с:
|
||||||
- Перечнем заказов набора, их 1С-номерами и шагами производства (раскрой, склейка, криволинейные, приёмка ОТК, отгрузка).
|
- Перечнем заказов набора, их 1С-номерами и шагами производства (раскрой, склейка, криволинейные, контроль качества, отгрузка).
|
||||||
- Телефоном и email клиента, городом, связанными счетами.
|
- Телефоном и email клиента, городом, связанными счетами.
|
||||||
- Текущим статусом слота.
|
- Текущим статусом слота.
|
||||||
3. Логист может запустить приглашение, назначить водителя или перейти к ручной обработке.
|
3. Логист может запустить приглашение, назначить водителя или перейти к ручной обработке.
|
||||||
|
|
@ -55,16 +55,16 @@
|
||||||
2. После закрытия всех заказов набора он переходит в «Завершено».
|
2. После закрытия всех заказов набора он переходит в «Завершено».
|
||||||
3. В истории появляется финальная запись, а чат закрывается для активных действий.
|
3. В истории появляется финальная запись, а чат закрывается для активных действий.
|
||||||
|
|
||||||
## Demo-скрипт для первого платного milestone
|
## Сценарий показа заказчику
|
||||||
|
|
||||||
1. Зайти под логистом (email: `mk7029953@yandex.ru`).
|
1. Зайти под логистом.
|
||||||
2. На дашборде увидеть LogisticsReadinessBoard с наборами:
|
2. На дашборде увидеть `LogisticsReadinessBoard` с наборами:
|
||||||
- Волкова М.А. — «На подходе» (кухня готова, столешница ещё в производстве).
|
- Волкова М.А. — «На подходе» (кухня готова, столешница ещё в производстве).
|
||||||
- Савин А.П. — «Готово к запуску» (все заказы приняты ОТК).
|
- Савин А.П. — «Готово к запуску» (все заказы прошли контроль качества).
|
||||||
- Тарасова Е.И. — «Ожидает клиента» (приглашение отправлено).
|
- Тарасова Е.И. — «Ожидает клиента» (приглашение отправлено).
|
||||||
- Фролова И.Д. — «Нужна ручная работа» (платное хранение).
|
- Фролова И.Д. — «Нужна ручная работа» (платное хранение).
|
||||||
- Орлова Н.С. — «Завершено».
|
- Орлова Н.С. — «Завершено».
|
||||||
3. Кликнуть по набору Савина — увидеть source-поля, production-шаги, готовность к запуску.
|
3. Кликнуть по набору Савина — увидеть source-поля, production-шаги, готовность к запуску.
|
||||||
4. Перейти на публичную страницу приглашения — увидеть DeliverySlotsPicker с выбором даты и половины дня.
|
4. Перейти на публичную страницу приглашения — увидеть `DeliverySlotsPicker` с выбором даты и половины дня.
|
||||||
5. Зайти под водителем — увидеть назначенные доставки с адресами и быстрыми действиями.
|
5. Зайти под водителем — увидеть назначенные доставки с адресами и быстрыми действиями.
|
||||||
6. Зайти под несуществующим email — увидеть «Email не найден в системе. Обратитесь к администратору.»
|
6. Зайти под несуществующим email — увидеть «Email не найден в системе. Обратитесь к администратору.»
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import reactHooks from "eslint-plugin-react-hooks";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
ignores: ["dist/**", "node_modules/**"],
|
ignores: ["dist/**", "node_modules/**", ".worktrees/**"],
|
||||||
},
|
},
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "Школьное питание Demo",
|
"name": "Школьное питание",
|
||||||
"short_name": "Школьное питание",
|
"short_name": "Школьное питание",
|
||||||
"description": "PWA-демо панели заказов и доставки с офлайн-доступом после первого запуска.",
|
"description": "Панель управления доставкой заказов с доступом к кабинетам логиста, водителя и менеджера.",
|
||||||
"start_url": "/dashboard",
|
"start_url": "/dashboard",
|
||||||
"scope": "/",
|
"scope": "/",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,8 @@ export const UserDirectoryPanel = ({ currentUser, users }) => {
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full text-sm text-[var(--color-text-muted)]">
|
<div className="w-full text-sm text-[var(--color-text-muted)]">
|
||||||
Каналы: Телеграм {user.botBindings?.telegram || "не привязан"} · ВКонтакте{" "}
|
Контакты для уведомлений: {user.phone || "телефон не указан"} ·{" "}
|
||||||
{user.botBindings?.vk || "не привязан"} · Макс{" "}
|
{user.email || "email не указан"}
|
||||||
{user.botBindings?.messengerMax || "не привязан"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,7 @@ const defaultState = {
|
||||||
email: "",
|
email: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
role: "manager",
|
role: "manager",
|
||||||
botLinkMode: "phone",
|
notifyBy: "phone",
|
||||||
telegram: "",
|
|
||||||
vk: "",
|
|
||||||
messengerMax: "",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UserOnboardingPanel = () => {
|
export const UserOnboardingPanel = () => {
|
||||||
|
|
@ -45,12 +42,10 @@ export const UserOnboardingPanel = () => {
|
||||||
return (
|
return (
|
||||||
<Panel className="space-y-5 p-6">
|
<Panel className="space-y-5 p-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">Добавление пользователя и привязка к ботам</h3>
|
<h3 className="text-lg font-semibold">Добавление пользователя</h3>
|
||||||
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
<p className="mt-3 text-sm leading-6 text-[var(--color-text-muted)]">
|
||||||
Практичный сценарий: сотрудник добавляется по электронной почте и телефону, получает роль, после
|
Сотрудник добавляется по электронной почте и телефону, получает роль и может получать
|
||||||
чего к нему привязываются идентификаторы каналов. Для ботов лучше иметь два варианта
|
рабочие уведомления выбранным способом.
|
||||||
привязки: по номеру телефона и по имени пользователя или идентификатору в конкретном
|
|
||||||
мессенджере.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -78,27 +73,12 @@ export const UserOnboardingPanel = () => {
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
value={form.botLinkMode}
|
value={form.notifyBy}
|
||||||
onChange={(event) => updateField("botLinkMode", event.target.value)}
|
onChange={(event) => updateField("notifyBy", event.target.value)}
|
||||||
>
|
>
|
||||||
<option value="phone">Привязка по телефону</option>
|
<option value="phone">Уведомления по SMS</option>
|
||||||
<option value="account">Привязка по аккаунту или идентификатору</option>
|
<option value="email">Уведомления по email</option>
|
||||||
</Select>
|
</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>
|
</div>
|
||||||
|
|
||||||
<Button onClick={handleAddDraft}>Добавить в список приглашений</Button>
|
<Button onClick={handleAddDraft}>Добавить в список приглашений</Button>
|
||||||
|
|
@ -114,8 +94,7 @@ export const UserOnboardingPanel = () => {
|
||||||
{draft.email} · {draft.phone} · {ROLE_LABELS[draft.role]}
|
{draft.email} · {draft.phone} · {ROLE_LABELS[draft.role]}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm text-[var(--color-text-muted)]">
|
<div className="mt-2 text-sm text-[var(--color-text-muted)]">
|
||||||
Способ привязки:{" "}
|
Уведомления: {draft.notifyBy === "phone" ? "SMS" : "email"}
|
||||||
{draft.botLinkMode === "phone" ? "по телефону" : "по аккаунту или идентификатору"}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,19 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ROLE_LABELS } from "../../constants/roles";
|
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { Input } from "../UI/Input";
|
import { Input } from "../UI/Input";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { Select } from "../UI/Select";
|
|
||||||
|
|
||||||
export const OtpLoginForm = ({
|
export const OtpLoginForm = ({
|
||||||
email,
|
email,
|
||||||
setEmail,
|
setEmail,
|
||||||
roleHint,
|
|
||||||
setRoleHint,
|
|
||||||
otp,
|
otp,
|
||||||
setOtp,
|
setOtp,
|
||||||
isOtpSent,
|
isOtpSent,
|
||||||
isLoading,
|
isLoading,
|
||||||
isDemoMode,
|
|
||||||
isRoleSwitchMode,
|
|
||||||
onRequestOtp,
|
onRequestOtp,
|
||||||
onVerifyOtp,
|
onVerifyOtp,
|
||||||
error,
|
error,
|
||||||
}) => {
|
}) => {
|
||||||
const isServiceAccessMode =
|
|
||||||
Boolean(isRoleSwitchMode) || String(email || "").trim().toLowerCase() === "roles@local";
|
|
||||||
const showsLocalRolePicker = !isOtpSent && (isServiceAccessMode || isDemoMode);
|
|
||||||
const submitLabel = isServiceAccessMode ? "Войти без кода" : "Отправить код";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel className="w-full max-w-md p-5 sm:p-8">
|
<Panel className="w-full max-w-md p-5 sm:p-8">
|
||||||
<div className="mb-6 space-y-2 sm:mb-8">
|
<div className="mb-6 space-y-2 sm:mb-8">
|
||||||
|
|
@ -34,10 +23,11 @@ export const OtpLoginForm = ({
|
||||||
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">
|
<h1 className="text-2xl font-semibold leading-tight sm:text-3xl">
|
||||||
Вход по email и коду
|
Вход по email и коду
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
{!isOtpSent ? (
|
||||||
Введите email, и код придет на почту. Для быстрого доступа к рабочим кабинетам можно
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
использовать служебный адрес и выбрать роль вручную.
|
Введите email, и мы отправим одноразовый код для входа.
|
||||||
</p>
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -49,41 +39,11 @@ export const OtpLoginForm = ({
|
||||||
id="email"
|
id="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(event) => setEmail(event.target.value)}
|
onChange={(event) => setEmail(event.target.value)}
|
||||||
placeholder="Введите email"
|
placeholder="name@company.ru"
|
||||||
type="email"
|
type="email"
|
||||||
disabled={isDemoMode}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showsLocalRolePicker ? (
|
|
||||||
<div className="space-y-2 rounded-3xl border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4">
|
|
||||||
<p className="text-xs uppercase tracking-[0.22em] text-[var(--color-text-muted)]">
|
|
||||||
{isServiceAccessMode ? "Служебный вход" : "Локальный вход"}
|
|
||||||
</p>
|
|
||||||
<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>
|
|
||||||
<p className="text-xs text-[var(--color-text-muted)]">
|
|
||||||
{isServiceAccessMode
|
|
||||||
? "Выберите кабинет и войдите сразу без подтверждения кода."
|
|
||||||
: "Локальный вход использует единый адрес и код 000000."}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{isOtpSent && (
|
{isOtpSent && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="otp">
|
<label className="text-sm text-[var(--color-text-muted)]" htmlFor="otp">
|
||||||
|
|
@ -109,7 +69,7 @@ export const OtpLoginForm = ({
|
||||||
|
|
||||||
{!isOtpSent ? (
|
{!isOtpSent ? (
|
||||||
<Button className="w-full" onClick={onRequestOtp} disabled={isLoading || !email}>
|
<Button className="w-full" onClick={onRequestOtp} disabled={isLoading || !email}>
|
||||||
{isLoading ? "Проверяем..." : submitLabel}
|
{isLoading ? "Проверяем..." : "Отправить код"}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button className="w-full" onClick={onVerifyOtp} disabled={isLoading || !otp}>
|
<Button className="w-full" onClick={onVerifyOtp} disabled={isLoading || !otp}>
|
||||||
|
|
@ -118,20 +78,6 @@ export const OtpLoginForm = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
className={[
|
|
||||||
"mt-6 rounded-3xl p-4 text-sm",
|
|
||||||
showsLocalRolePicker
|
|
||||||
? "border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] text-[var(--color-text)]"
|
|
||||||
: "bg-[var(--color-accent-soft)] text-[var(--color-text)]",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{isServiceAccessMode
|
|
||||||
? "Служебный вход открывает кабинеты менеджера, логиста и водителя без ожидания кода."
|
|
||||||
: isDemoMode
|
|
||||||
? "Локальный режим позволяет открыть интерфейс и проверить структуру кабинетов."
|
|
||||||
: "Рабочий режим: код отправляется на email, а доступ определяется учетной записью в системе."}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
</Panel>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -6,13 +6,10 @@ import { OtpLoginForm } from "./OtpLoginForm";
|
||||||
const baseProps = {
|
const baseProps = {
|
||||||
email: "skylanguage@yandex.ru",
|
email: "skylanguage@yandex.ru",
|
||||||
setEmail: () => {},
|
setEmail: () => {},
|
||||||
roleHint: "manager",
|
|
||||||
setRoleHint: () => {},
|
|
||||||
otp: "",
|
otp: "",
|
||||||
setOtp: () => {},
|
setOtp: () => {},
|
||||||
isOtpSent: false,
|
isOtpSent: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isDemoMode: false,
|
|
||||||
onRequestOtp: () => {},
|
onRequestOtp: () => {},
|
||||||
onVerifyOtp: () => {},
|
onVerifyOtp: () => {},
|
||||||
error: "",
|
error: "",
|
||||||
|
|
@ -23,20 +20,10 @@ describe("OtpLoginForm", () => {
|
||||||
const markup = renderToStaticMarkup(<OtpLoginForm {...baseProps} />).toLowerCase();
|
const markup = renderToStaticMarkup(<OtpLoginForm {...baseProps} />).toLowerCase();
|
||||||
|
|
||||||
expect(markup).toContain("введите email");
|
expect(markup).toContain("введите email");
|
||||||
expect(markup).toContain("доступ определяется учетной записью");
|
expect(markup).toContain("одноразовый код");
|
||||||
expect(markup).not.toContain("роль для быстрого входа");
|
expect(markup).not.toContain("служебный вход");
|
||||||
expect(markup).not.toContain("демо-режим");
|
expect(markup).not.toContain("локальный вход");
|
||||||
});
|
expect(markup).not.toContain("рабочий режим");
|
||||||
|
|
||||||
it("shows role selection for the special access email without demo wording", () => {
|
|
||||||
const markup = renderToStaticMarkup(
|
|
||||||
<OtpLoginForm {...baseProps} email="roles@local" />,
|
|
||||||
).toLowerCase();
|
|
||||||
|
|
||||||
expect(markup).toContain("служебный вход");
|
|
||||||
expect(markup).toContain("роль для быстрого входа");
|
|
||||||
expect(markup).toContain("войти без кода");
|
|
||||||
expect(markup).not.toContain("демо-режим");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tells operators to check inbox and spam after OTP is sent", () => {
|
it("tells operators to check inbox and spam after OTP is sent", () => {
|
||||||
|
|
@ -46,6 +33,8 @@ describe("OtpLoginForm", () => {
|
||||||
|
|
||||||
expect(markup).toContain("входящие");
|
expect(markup).toContain("входящие");
|
||||||
expect(markup).toContain("спам");
|
expect(markup).toContain("спам");
|
||||||
|
expect(markup).not.toContain("мы отправим одноразовый код");
|
||||||
|
expect(markup).not.toContain("рабочие кабинеты");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows unknown-email admin-help message when error matches", () => {
|
it("shows unknown-email admin-help message when error matches", () => {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,7 @@ export const ChatTimeline = ({ messages }) => {
|
||||||
if (!messages.length) {
|
if (!messages.length) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-[24px] border border-dashed border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
|
<div className="rounded-[24px] border border-dashed border-[var(--color-border)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
Пока нет сообщений. Здесь появится история переписки с клиентом из ВКонтакте, Телеграма
|
Пока нет сообщений. Здесь появится история переписки с клиентом.
|
||||||
и Макса.
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
import React from "react";
|
||||||
|
import { Panel } from "../UI/Panel";
|
||||||
|
|
||||||
|
const workflowItems = [
|
||||||
|
{
|
||||||
|
title: "1С",
|
||||||
|
text: "Заказы создаются и проходят производство во внешней системе. В приложение попадает уже доставочная часть работы.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Логист",
|
||||||
|
text: "Проверяет готовность набора доставки, запускает согласование с клиентом и разбирает исключения.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Клиент",
|
||||||
|
text: "Открывает публичную ссылку и выбирает дату и половину дня доставки без входа в кабинет.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Водитель",
|
||||||
|
text: "Видит назначенные доставки, адреса, состав заказа и отмечает движение по маршруту.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const roleItems = [
|
||||||
|
["Менеджер", "Ищет заказ, открывает карточку, смотрит состав, клиента и текущий статус."],
|
||||||
|
["Логист", "Работает с наборами доставки: готовность, согласование, проблемные случаи."],
|
||||||
|
["Водитель", "Работает только со своими доставками и маршрутными статусами."],
|
||||||
|
["Клиент", "Выбирает удобное окно доставки по ссылке."],
|
||||||
|
];
|
||||||
|
|
||||||
|
const faqItems = [
|
||||||
|
["Что такое набор доставки?", "Это один или несколько заказов одного клиента, которые нужно доставить вместе."],
|
||||||
|
["Когда доставка готова к запуску?", "Когда все заказы в наборе прошли контроль качества и ещё не отгружены."],
|
||||||
|
["Какие каналы связи используются?", "SMS и email. Остальные каналы не участвуют в текущем сценарии."],
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ProductGuidePanel = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 xl:space-y-8">
|
||||||
|
<Panel className="space-y-4 p-5 md:p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">Как работает приложение</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{workflowItems.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.title}
|
||||||
|
className="rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
|
||||||
|
>
|
||||||
|
<div className="text-sm font-semibold">{item.title}</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">{item.text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="space-y-4 p-5 md:p-6">
|
||||||
|
<h3 className="text-lg font-semibold">Кто за что отвечает</h3>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
{roleItems.map(([role, text]) => (
|
||||||
|
<div
|
||||||
|
key={role}
|
||||||
|
className="rounded-[18px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4"
|
||||||
|
>
|
||||||
|
<div className="font-semibold">{role}</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">{text}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
|
<Panel className="space-y-4 p-5 md:p-6">
|
||||||
|
<h3 className="text-lg font-semibold">Частые вопросы</h3>
|
||||||
|
<div className="divide-y divide-[var(--color-border)]">
|
||||||
|
{faqItems.map(([question, answer]) => (
|
||||||
|
<div key={question} className="py-4 first:pt-0 last:pb-0">
|
||||||
|
<div className="font-semibold">{question}</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-[var(--color-text-muted)]">{answer}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
import React from "react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ProductGuidePanel } from "./ProductGuidePanel";
|
||||||
|
|
||||||
|
describe("ProductGuidePanel", () => {
|
||||||
|
it("shows customer-facing help without internal presentation notes", () => {
|
||||||
|
const markup = renderToStaticMarkup(<ProductGuidePanel />);
|
||||||
|
|
||||||
|
expect(markup).toContain("Как работает приложение");
|
||||||
|
expect(markup).toContain("контроль качества");
|
||||||
|
expect(markup).not.toContain("Короткая карта продукта");
|
||||||
|
expect(markup).not.toContain("Для показа");
|
||||||
|
expect(markup).not.toContain("ОТК");
|
||||||
|
expect(markup).not.toContain("Что показывать заказчику");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { ROLE_LABELS } from "../../constants/roles";
|
|
||||||
import { DELIVERY_SET_BUCKET_LABELS } from "../../services/deliverySetViews";
|
|
||||||
import { Badge } from "../UI/Badge";
|
|
||||||
import { Panel } from "../UI/Panel";
|
|
||||||
|
|
||||||
const ROLE_MODULES = {
|
|
||||||
manager: [
|
|
||||||
"Поиск по заказу, клиенту и телефону",
|
|
||||||
"Просмотр состава и статуса заказа",
|
|
||||||
"Работа только с доставочным реестром",
|
|
||||||
],
|
|
||||||
logistician: ["Готовность заказов на сегодня", "Слоты завтра и послезавтра", "Половины дня и статус доставки"],
|
|
||||||
driver: [
|
|
||||||
"Назначенные доставки",
|
|
||||||
"Адрес, состав и слот доставки",
|
|
||||||
"Быстрые статусы по маршруту",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
export const RoleWorkspacePanel = ({ role, deliverySetBuckets }) => {
|
|
||||||
const modules = ROLE_MODULES[role] || [];
|
|
||||||
const totalSets = deliverySetBuckets
|
|
||||||
? Object.values(deliverySetBuckets).reduce((sum, sets) => sum + sets.length, 0)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
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>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Badge tone="accent">{ROLE_LABELS[role]}</Badge>
|
|
||||||
{totalSets !== null ? (
|
|
||||||
<Badge tone="neutral">{totalSets} наборов доставки</Badge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{deliverySetBuckets && role === "logistician" ? (
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{Object.entries(DELIVERY_SET_BUCKET_LABELS).map(([key, label]) => {
|
|
||||||
const count = deliverySetBuckets[key]?.length || 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Badge key={key} tone={count > 0 ? "accent" : "neutral"}>
|
|
||||||
{label}: {count}
|
|
||||||
</Badge>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Panel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -10,7 +10,7 @@ export const BotControlPanel = ({
|
||||||
onReschedule,
|
onReschedule,
|
||||||
canManageLogistics,
|
canManageLogistics,
|
||||||
}) => {
|
}) => {
|
||||||
const [channel, setChannel] = React.useState(selectedOrder?.customer.messenger || "Телеграм");
|
const [channel, setChannel] = React.useState(selectedOrder?.customer.messenger || "СМС");
|
||||||
const [message, setMessage] = React.useState(
|
const [message, setMessage] = React.useState(
|
||||||
"Заказ готов к отгрузке. Выберите дату и половину дня для доставки.",
|
"Заказ готов к отгрузке. Выберите дату и половину дня для доставки.",
|
||||||
);
|
);
|
||||||
|
|
@ -18,7 +18,7 @@ export const BotControlPanel = ({
|
||||||
const [time, setTime] = React.useState("Первая половина дня");
|
const [time, setTime] = React.useState("Первая половина дня");
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setChannel(selectedOrder?.customer.messenger || "Телеграм");
|
setChannel(selectedOrder?.customer.messenger || "СМС");
|
||||||
}, [selectedOrder]);
|
}, [selectedOrder]);
|
||||||
|
|
||||||
if (!selectedOrder) {
|
if (!selectedOrder) {
|
||||||
|
|
@ -28,7 +28,7 @@ export const BotControlPanel = ({
|
||||||
return (
|
return (
|
||||||
<Panel className="space-y-4 p-5">
|
<Panel className="space-y-4 p-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">Управление ботами</h3>
|
<h3 className="text-lg font-semibold">Уведомления клиенту</h3>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
Отправка уведомлений и фиксация ответов клиента в истории заказа.
|
Отправка уведомлений и фиксация ответов клиента в истории заказа.
|
||||||
</p>
|
</p>
|
||||||
|
|
@ -36,10 +36,8 @@ export const BotControlPanel = ({
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
<Select value={channel} onChange={(event) => setChannel(event.target.value)}>
|
<Select value={channel} onChange={(event) => setChannel(event.target.value)}>
|
||||||
<option value="Телеграм">Телеграм</option>
|
|
||||||
<option value="ВКонтакте">ВКонтакте</option>
|
|
||||||
<option value="Макс">Макс</option>
|
|
||||||
<option value="СМС">СМС</option>
|
<option value="СМС">СМС</option>
|
||||||
|
<option value="Эл. почта">Эл. почта</option>
|
||||||
</Select>
|
</Select>
|
||||||
<Input value={date} onChange={(event) => setDate(event.target.value)} type="date" />
|
<Input value={date} onChange={(event) => setDate(event.target.value)} type="date" />
|
||||||
<Select value={time} onChange={(event) => setTime(event.target.value)}>
|
<Select value={time} onChange={(event) => setTime(event.target.value)}>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const PRODUCTION_STEP_LABELS = {
|
||||||
sourceGlueAt: "\u0421\u043A\u043B\u0435\u0439\u043A\u0430",
|
sourceGlueAt: "\u0421\u043A\u043B\u0435\u0439\u043A\u0430",
|
||||||
sourceHGlueAt: "H-\u0441\u043A\u043B\u0435\u0439\u043A\u0430",
|
sourceHGlueAt: "H-\u0441\u043A\u043B\u0435\u0439\u043A\u0430",
|
||||||
sourceCurveAt: "\u041A\u0440\u0438\u0432\u043E\u043B\u0438\u043D\u0435\u0439\u043D\u044B\u0435",
|
sourceCurveAt: "\u041A\u0440\u0438\u0432\u043E\u043B\u0438\u043D\u0435\u0439\u043D\u044B\u0435",
|
||||||
sourceAcceptAt: "\u041F\u0440\u0438\u0451\u043C\u043A\u0430 \u041E\u0422\u041A",
|
sourceAcceptAt: "Контроль качества",
|
||||||
sourceShipAt: "\u041E\u0442\u0433\u0440\u0443\u0437\u043A\u0430",
|
sourceShipAt: "\u041E\u0442\u0433\u0440\u0443\u0437\u043A\u0430",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -64,8 +64,8 @@ export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => {
|
||||||
{deliverySet.readyReason ? (
|
{deliverySet.readyReason ? (
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">
|
<div className="text-sm text-[var(--color-text-muted)]">
|
||||||
{deliverySet.readyReason === "all_accepted"
|
{deliverySet.readyReason === "all_accepted"
|
||||||
? "Все заказы набора приняты ОТК, можно запускать доставку."
|
? "Все заказы набора прошли контроль качества, можно запускать доставку."
|
||||||
: "Не все заказы набора ещё приняты ОТК."}
|
: "Не все заказы набора ещё прошли контроль качества."}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
@ -129,4 +129,4 @@ export const DeliverySetDetailPanel = ({ deliverySet, onClose }) => {
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,9 @@ const BUCKET_ICONS = {
|
||||||
|
|
||||||
export const LogisticsReadinessBoard = ({ deliverySetBuckets, onSelectSet }) => {
|
export const LogisticsReadinessBoard = ({ deliverySetBuckets, onSelectSet }) => {
|
||||||
const bucketKeys = Object.keys(DELIVERY_SET_BUCKET_LABELS);
|
const bucketKeys = Object.keys(DELIVERY_SET_BUCKET_LABELS);
|
||||||
|
const buckets = deliverySetBuckets || {};
|
||||||
const totalSets = bucketKeys.reduce(
|
const totalSets = bucketKeys.reduce(
|
||||||
(sum, key) => sum + (deliverySetBuckets[key]?.length || 0),
|
(sum, key) => sum + (buckets[key]?.length || 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -42,7 +43,7 @@ export const LogisticsReadinessBoard = ({ deliverySetBuckets, onSelectSet }) =>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
{bucketKeys.map((bucketKey) => {
|
{bucketKeys.map((bucketKey) => {
|
||||||
const sets = deliverySetBuckets[bucketKey] || [];
|
const sets = buckets[bucketKey] || [];
|
||||||
const label = DELIVERY_SET_BUCKET_LABELS[bucketKey];
|
const label = DELIVERY_SET_BUCKET_LABELS[bucketKey];
|
||||||
const tone = BUCKET_TONES[bucketKey];
|
const tone = BUCKET_TONES[bucketKey];
|
||||||
const icon = BUCKET_ICONS[bucketKey];
|
const icon = BUCKET_ICONS[bucketKey];
|
||||||
|
|
@ -67,50 +68,55 @@ export const LogisticsReadinessBoard = ({ deliverySetBuckets, onSelectSet }) =>
|
||||||
<Badge tone={tone}>{sets.length}</Badge>
|
<Badge tone={tone}>{sets.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{sets.map((set) => (
|
{sets.map((set) => {
|
||||||
<button
|
const setOrders = Array.isArray(set.orders) ? set.orders : [];
|
||||||
key={set.key}
|
const orderCount = set.orderCount ?? setOrders.length;
|
||||||
className="w-full rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5 text-left transition hover:bg-[var(--color-accent-soft)]"
|
|
||||||
onClick={() => {
|
|
||||||
if (onSelectSet) {
|
|
||||||
onSelectSet(set);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div className="min-w-0">
|
|
||||||
<div className="font-semibold truncate">{set.name}</div>
|
|
||||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
|
||||||
{set.sourceCustomerCity || "\u2014"} \u00B7 {set.orderCount}{" "}
|
|
||||||
{set.orderCount === 1 ? "заказ" : set.orderCount < 5 ? "заказа" : "заказов"}
|
|
||||||
</div>
|
|
||||||
{set.linkedBillTexts ? (
|
|
||||||
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
|
|
||||||
Связанные счета: {set.linkedBillTexts}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<Badge tone={tone}>{label}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap gap-2">
|
return (
|
||||||
{set.orders.map((order) => (
|
<button
|
||||||
<span
|
key={set.key}
|
||||||
key={order.id}
|
className="w-full rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-5 text-left transition hover:bg-[var(--color-accent-soft)]"
|
||||||
className="rounded-full bg-[var(--color-surface)] px-3 py-1 text-xs text-[var(--color-text-muted)]"
|
onClick={() => {
|
||||||
>
|
if (onSelectSet) {
|
||||||
{order.orderNumber}
|
onSelectSet(set);
|
||||||
{order.sourceOrderNumber ? ` (${order.sourceOrderNumber})` : ""}
|
}
|
||||||
</span>
|
}}
|
||||||
))}
|
type="button"
|
||||||
</div>
|
>
|
||||||
</button>
|
<div className="flex items-center justify-between gap-3">
|
||||||
))}
|
<div className="min-w-0">
|
||||||
|
<div className="truncate font-semibold">{set.name}</div>
|
||||||
|
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{set.sourceCustomerCity || "\u2014"} \u00B7 {orderCount}{" "}
|
||||||
|
{orderCount === 1 ? "заказ" : orderCount < 5 ? "заказа" : "заказов"}
|
||||||
|
</div>
|
||||||
|
{set.linkedBillTexts ? (
|
||||||
|
<div className="mt-1 text-xs text-[var(--color-text-muted)]">
|
||||||
|
Связанные счета: {set.linkedBillTexts}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<Badge tone={tone}>{label}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{setOrders.map((order) => (
|
||||||
|
<span
|
||||||
|
key={order.id}
|
||||||
|
className="rounded-full bg-[var(--color-surface)] px-3 py-1 text-xs text-[var(--color-text-muted)]"
|
||||||
|
>
|
||||||
|
{order.orderNumber}
|
||||||
|
{order.sourceOrderNumber ? ` (${order.sourceOrderNumber})` : ""}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ const order = {
|
||||||
name: "Мария Волкова",
|
name: "Мария Волкова",
|
||||||
phone: "+7 978 000-12-31",
|
phone: "+7 978 000-12-31",
|
||||||
address: "Симферополь",
|
address: "Симферополь",
|
||||||
messenger: "Телеграм",
|
messenger: "СМС",
|
||||||
},
|
},
|
||||||
items: ["Кухня | 1 шт"],
|
items: ["Кухня | 1 шт"],
|
||||||
chatMessages: [],
|
chatMessages: [],
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ const initialForm = {
|
||||||
customerName: "",
|
customerName: "",
|
||||||
customerPhone: "",
|
customerPhone: "",
|
||||||
customerAddress: "",
|
customerAddress: "",
|
||||||
messenger: "Телеграм",
|
messenger: "СМС",
|
||||||
managerId: "",
|
managerId: "",
|
||||||
deliveryDate: "",
|
deliveryDate: "",
|
||||||
items: "",
|
items: "",
|
||||||
|
|
@ -120,7 +120,7 @@ export const OrderEditorPanel = ({
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold">Управление заказом</h3>
|
<h3 className="text-lg font-semibold">Управление заказом</h3>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
Редактирование импортированного из 1С заказа с полями клиента, канала связи и даты доставки.
|
Редактирование импортированного из 1С заказа с полями клиента, способом связи и датой доставки.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!createOnly ? (
|
{!createOnly ? (
|
||||||
|
|
@ -170,9 +170,6 @@ export const OrderEditorPanel = ({
|
||||||
value={form.messenger}
|
value={form.messenger}
|
||||||
onChange={(event) => updateField("messenger", event.target.value)}
|
onChange={(event) => updateField("messenger", event.target.value)}
|
||||||
>
|
>
|
||||||
<option value="Телеграм">Телеграм</option>
|
|
||||||
<option value="ВКонтакте">ВКонтакте</option>
|
|
||||||
<option value="Макс">Макс</option>
|
|
||||||
<option value="СМС">СМС</option>
|
<option value="СМС">СМС</option>
|
||||||
<option value="Эл. почта">Эл. почта</option>
|
<option value="Эл. почта">Эл. почта</option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,90 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { ORDER_STATUSES } from "../../constants/orderStatuses";
|
import { DELIVERY_REGISTRY_FILTER_STATUSES } from "../../constants/orderStatuses";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Button } from "../UI/Button";
|
import { Button } from "../UI/Button";
|
||||||
import { Input } from "../UI/Input";
|
import { Input } from "../UI/Input";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
import { Select } from "../UI/Select";
|
|
||||||
|
|
||||||
const messengers = ["Телеграм", "ВКонтакте", "Макс", "СМС", "Эл. почта"];
|
const messengers = ["СМС", "Эл. почта"];
|
||||||
|
const statusOptions = [
|
||||||
|
{ value: "all", label: "Все статусы" },
|
||||||
|
...DELIVERY_REGISTRY_FILTER_STATUSES.map((status) => ({ value: status, label: status })),
|
||||||
|
];
|
||||||
|
const messengerOptions = [
|
||||||
|
{ value: "all", label: "Все каналы" },
|
||||||
|
...messengers.map((messenger) => ({ value: messenger, label: messenger })),
|
||||||
|
];
|
||||||
|
|
||||||
|
const FilterMenu = ({ label, value, options, isOpen, onToggle, onChange, onClose }) => {
|
||||||
|
const selectedLabel = options.find((option) => option.value === value)?.label || label;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
onBlur={(event) => {
|
||||||
|
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"flex w-full items-center justify-between gap-3 border border-[var(--color-border)]",
|
||||||
|
"bg-[var(--color-surface)] px-4 py-3 text-left text-sm text-[var(--color-text)] transition",
|
||||||
|
isOpen
|
||||||
|
? "rounded-t-2xl rounded-b-none border-[var(--color-accent)] border-b-transparent bg-[var(--color-dropdown-surface)]"
|
||||||
|
: "rounded-2xl hover:bg-[var(--color-surface-strong)]",
|
||||||
|
].join(" ")}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<span className="truncate">{selectedLabel}</span>
|
||||||
|
<span className="text-[var(--color-text-muted)]">v</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen ? (
|
||||||
|
<div
|
||||||
|
className="overflow-hidden rounded-b-2xl border border-t-0 border-[var(--color-accent)] bg-[var(--color-dropdown-surface)] px-2 pb-2 pt-1"
|
||||||
|
role="listbox"
|
||||||
|
aria-label={label}
|
||||||
|
>
|
||||||
|
{options.map((option) => {
|
||||||
|
const selected = option.value === value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
className={[
|
||||||
|
"flex w-full items-center justify-between rounded-2xl px-3 py-2.5 text-left text-sm transition",
|
||||||
|
selected
|
||||||
|
? "bg-[var(--color-accent-soft)] font-semibold text-[var(--color-text)]"
|
||||||
|
: "text-[var(--color-text-muted)] hover:bg-[var(--color-accent-soft)] hover:text-[var(--color-text)]",
|
||||||
|
].join(" ")}
|
||||||
|
role="option"
|
||||||
|
aria-selected={selected}
|
||||||
|
onMouseDown={(event) => event.preventDefault()}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(option.value);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{option.label}</span>
|
||||||
|
{selected ? <span className="text-[var(--color-accent)]">✓</span> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const OrderFilters = ({ filters, setFilters }) => {
|
export const OrderFilters = ({ filters, setFilters }) => {
|
||||||
const [isMobileFiltersOpen, setIsMobileFiltersOpen] = React.useState(false);
|
const [isMobileFiltersOpen, setIsMobileFiltersOpen] = React.useState(false);
|
||||||
|
const [openMenu, setOpenMenu] = React.useState(null);
|
||||||
|
|
||||||
const activeChips = [
|
const activeChips = [
|
||||||
filters.status !== "all" ? { key: "status", label: filters.status } : null,
|
filters.status !== "all" ? { key: "status", label: filters.status } : null,
|
||||||
|
|
@ -36,27 +111,29 @@ export const OrderFilters = ({ filters, setFilters }) => {
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
{renderFilterField(
|
{renderFilterField(
|
||||||
"Статус",
|
"Статус",
|
||||||
<Select value={filters.status} onChange={(event) => updateFilter("status", event.target.value)}>
|
<FilterMenu
|
||||||
<option value="all">Все статусы</option>
|
label="Статус"
|
||||||
{ORDER_STATUSES.map((status) => (
|
value={filters.status}
|
||||||
<option key={status} value={status}>
|
options={statusOptions}
|
||||||
{status}
|
isOpen={openMenu === "status"}
|
||||||
</option>
|
onToggle={() => setOpenMenu((current) => (current === "status" ? null : "status"))}
|
||||||
))}
|
onChange={(value) => updateFilter("status", value)}
|
||||||
</Select>,
|
onClose={() => setOpenMenu(null)}
|
||||||
|
/>,
|
||||||
showLabels,
|
showLabels,
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{renderFilterField(
|
{renderFilterField(
|
||||||
"Канал",
|
"Канал",
|
||||||
<Select value={filters.messenger} onChange={(event) => updateFilter("messenger", event.target.value)}>
|
<FilterMenu
|
||||||
<option value="all">Все каналы</option>
|
label="Канал"
|
||||||
{messengers.map((messenger) => (
|
value={filters.messenger}
|
||||||
<option key={messenger} value={messenger}>
|
options={messengerOptions}
|
||||||
{messenger}
|
isOpen={openMenu === "messenger"}
|
||||||
</option>
|
onToggle={() => setOpenMenu((current) => (current === "messenger" ? null : "messenger"))}
|
||||||
))}
|
onChange={(value) => updateFilter("messenger", value)}
|
||||||
</Select>,
|
onClose={() => setOpenMenu(null)}
|
||||||
|
/>,
|
||||||
showLabels,
|
showLabels,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -76,14 +153,18 @@ export const OrderFilters = ({ filters, setFilters }) => {
|
||||||
Фильтры
|
Фильтры
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{activeChips.length ? (
|
||||||
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
<div>
|
||||||
Активные фильтры
|
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
|
Активные фильтры
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{activeChips.map((chip) => (
|
||||||
|
<Badge key={chip.key}>{chip.label}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
) : null}
|
||||||
{activeChips.length ? activeChips.map((chip) => <Badge key={chip.key}>{chip.label}</Badge>) : <Badge>Нет</Badge>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{isMobileFiltersOpen
|
{isMobileFiltersOpen
|
||||||
? renderAdvancedFilters({
|
? renderAdvancedFilters({
|
||||||
className: "rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3",
|
className: "rounded-[20px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-3",
|
||||||
|
|
@ -92,18 +173,29 @@ export const OrderFilters = ({ filters, setFilters }) => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="hidden md:flex md:flex-col md:gap-4">
|
<div className="hidden md:flex md:flex-col md:gap-4">
|
||||||
<div className="grid gap-3 xl:grid-cols-[minmax(22rem,1.35fr)_minmax(0,1fr)] xl:items-start">
|
<div
|
||||||
|
className={[
|
||||||
|
"grid gap-3 xl:items-start",
|
||||||
|
activeChips.length ? "xl:grid-cols-[minmax(22rem,1.35fr)_minmax(0,1fr)]" : "",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Поиск по заявке, клиенту, телефону"
|
placeholder="Поиск по заявке, клиенту, телефону"
|
||||||
value={filters.query}
|
value={filters.query}
|
||||||
onChange={(event) => updateFilter("query", event.target.value)}
|
onChange={(event) => updateFilter("query", event.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="min-h-[44px] rounded-[20px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3">
|
{activeChips.length ? (
|
||||||
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">Активные фильтры</div>
|
<div className="min-h-[44px] rounded-[20px] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] px-4 py-3">
|
||||||
<div className="mt-2 flex flex-wrap gap-2">
|
<div className="text-xs uppercase tracking-[0.14em] text-[var(--color-text-muted)]">
|
||||||
{activeChips.length ? activeChips.map((chip) => <Badge key={chip.key}>{chip.label}</Badge>) : <Badge>Нет</Badge>}
|
Активные фильтры
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2">
|
||||||
|
{activeChips.map((chip) => (
|
||||||
|
<Badge key={chip.key}>{chip.label}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{renderAdvancedFilters({ showLabels: true })}
|
{renderAdvancedFilters({ showLabels: true })}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -22,13 +22,47 @@ describe("OrderFilters", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Поиск по заявке, клиенту, телефону");
|
expect(markup).toContain("Поиск по заявке, клиенту, телефону");
|
||||||
expect(markup).toContain("Активные фильтры");
|
expect(markup).not.toContain("Активные фильтры");
|
||||||
|
expect(markup).not.toContain("Нет");
|
||||||
expect(markup).toContain("Статус");
|
expect(markup).toContain("Статус");
|
||||||
expect(markup).toContain("Канал");
|
expect(markup).toContain("Канал");
|
||||||
|
expect(markup).toContain("aria-haspopup=\"listbox\"");
|
||||||
|
expect(markup).not.toContain("<select");
|
||||||
|
expect(markup).not.toContain("Новый");
|
||||||
|
expect(markup).not.toContain("Требует уточнения");
|
||||||
|
expect(markup).not.toContain("Подтверждён менеджером");
|
||||||
|
expect(markup).not.toContain("В очереди производства");
|
||||||
|
expect(markup).not.toContain("В производстве");
|
||||||
|
expect(markup).not.toContain("Отменён");
|
||||||
|
expect(markup).not.toContain("Телеграм");
|
||||||
|
expect(markup).not.toContain("ВКонтакте");
|
||||||
|
expect(markup).not.toContain("Макс");
|
||||||
expect(markup).not.toContain("Все этапы");
|
expect(markup).not.toContain("Все этапы");
|
||||||
expect(markup).not.toContain("Все зоны ответственности");
|
expect(markup).not.toContain("Все зоны ответственности");
|
||||||
expect(markup).not.toContain("Без фильтра по SLA");
|
expect(markup).not.toContain("Без фильтра по SLA");
|
||||||
expect(markup).not.toContain("Менеджер");
|
expect(markup).not.toContain("Менеджер");
|
||||||
expect(markup).not.toContain("Логист");
|
expect(markup).not.toContain("Логист");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows active filter chips only when filters are selected", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<OrderFilters
|
||||||
|
filters={{
|
||||||
|
query: "",
|
||||||
|
status: "Доставка согласована",
|
||||||
|
stage: "all",
|
||||||
|
ownerRole: "all",
|
||||||
|
agingState: "all",
|
||||||
|
managerId: "all",
|
||||||
|
logisticianId: "all",
|
||||||
|
messenger: "СМС",
|
||||||
|
}}
|
||||||
|
setFilters={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Активные фильтры");
|
||||||
|
expect(markup).toContain("Доставка согласована");
|
||||||
|
expect(markup).toContain("СМС");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { demoUsers } from "../../data/mockAppData";
|
||||||
import { formatDateTime } from "../../utils/formatters";
|
import { formatDateTime } from "../../utils/formatters";
|
||||||
import { Badge } from "../UI/Badge";
|
import { Badge } from "../UI/Badge";
|
||||||
import { Panel } from "../UI/Panel";
|
import { Panel } from "../UI/Panel";
|
||||||
|
import { OrderFilters } from "./OrderFilters";
|
||||||
|
|
||||||
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
|
const getUsers = (users) => (Array.isArray(users) && users.length ? users : demoUsers);
|
||||||
const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен";
|
const resolveUserName = (users, userId) => getUsers(users).find((user) => user.id === userId)?.name || "Не назначен";
|
||||||
|
|
@ -13,17 +14,21 @@ const buildOrderSummary = (order) => {
|
||||||
return `${leadItem}. ${leadComment}`;
|
return `${leadItem}. ${leadComment}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const OrdersTable = ({ orders, selectedOrderId, onOpenOrder, users }) => {
|
export const OrdersTable = ({ orders, selectedOrderId, onOpenOrder, users, filters, setFilters }) => {
|
||||||
return (
|
return (
|
||||||
<Panel className="overflow-hidden p-0">
|
<Panel className="p-0">
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-5 py-4">
|
<div className="space-y-4 border-b border-[var(--color-border)] px-5 py-4">
|
||||||
<div>
|
<div className="flex items-start justify-between gap-4">
|
||||||
<h2 className="text-lg font-semibold">Реестр заказов</h2>
|
<div>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
<h2 className="text-lg font-semibold">Реестр заказов</h2>
|
||||||
Клик по строке открывает карточку в модальном окне.
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
</p>
|
Поиск по номеру, клиенту и телефону.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge tone="neutral">{orders.length}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<Badge tone="neutral">{orders.length}</Badge>
|
|
||||||
|
{filters && setFilters ? <OrderFilters filters={filters} setFilters={setFilters} /> : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3 p-4 md:hidden">
|
<div className="space-y-3 p-4 md:hidden">
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ const orders = [
|
||||||
customer: {
|
customer: {
|
||||||
name: "Мария Волкова",
|
name: "Мария Волкова",
|
||||||
phone: "+7 978 000-12-31",
|
phone: "+7 978 000-12-31",
|
||||||
messenger: "Телеграм",
|
messenger: "СМС",
|
||||||
},
|
},
|
||||||
items: ["Кухня | 1 шт"],
|
items: ["Кухня | 1 шт"],
|
||||||
orderNotes: [{ text: "Подъезд узкий" }],
|
orderNotes: [{ text: "Подъезд узкий" }],
|
||||||
|
|
@ -24,11 +24,19 @@ const orders = [
|
||||||
describe("OrdersTable", () => {
|
describe("OrdersTable", () => {
|
||||||
it("renders desktop table and mobile card list", () => {
|
it("renders desktop table and mobile card list", () => {
|
||||||
const markup = renderToStaticMarkup(
|
const markup = renderToStaticMarkup(
|
||||||
<OrdersTable orders={orders} selectedOrderId={null} onOpenOrder={() => {}} />,
|
<OrdersTable
|
||||||
|
orders={orders}
|
||||||
|
selectedOrderId={null}
|
||||||
|
onOpenOrder={() => {}}
|
||||||
|
filters={{ search: "", status: "all", messenger: "all" }}
|
||||||
|
setFilters={() => {}}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("hidden overflow-x-auto md:block");
|
expect(markup).toContain("hidden overflow-x-auto md:block");
|
||||||
expect(markup).toContain("md:hidden");
|
expect(markup).toContain("md:hidden");
|
||||||
|
expect(markup).toContain("Поиск по номеру, клиенту и телефону.");
|
||||||
|
expect(markup).toContain("Поиск по заявке, клиенту, телефону");
|
||||||
expect(markup).toContain("CD-240031");
|
expect(markup).toContain("CD-240031");
|
||||||
expect(markup).toContain("Мария Волкова");
|
expect(markup).toContain("Мария Волкова");
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1 +1,13 @@
|
||||||
export { ORDER_STATUSES } from "./deliveryWorkflow";
|
export { ORDER_STATUSES } from "./deliveryWorkflow";
|
||||||
|
|
||||||
|
export const DELIVERY_REGISTRY_FILTER_STATUSES = [
|
||||||
|
"Готов к отгрузке",
|
||||||
|
"Ожидает согласования доставки",
|
||||||
|
"Доставка согласована",
|
||||||
|
"Назначен водитель",
|
||||||
|
"Загружен",
|
||||||
|
"В пути",
|
||||||
|
"Доставлен",
|
||||||
|
"Проблема доставки",
|
||||||
|
"Платное хранение",
|
||||||
|
];
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ import { supabase, hasSupabaseConfig } from "../supabaseClient";
|
||||||
|
|
||||||
const AuthContext = createContext(null);
|
const AuthContext = createContext(null);
|
||||||
const STORAGE_KEY = "construction-auth-local-user";
|
const STORAGE_KEY = "construction-auth-local-user";
|
||||||
export const DEMO_LOGIN_EMAIL = "local@local";
|
|
||||||
export const ROLE_SWITCH_ENTRY_EMAIL = "roles@local";
|
|
||||||
export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя.";
|
export const PROFILE_LOAD_ERROR = "Не удалось загрузить профиль пользователя.";
|
||||||
export const UNKNOWN_EMAIL_ERROR = "Email не найден в системе. Обратитесь к администратору.";
|
export const UNKNOWN_EMAIL_ERROR = "Email не найден в системе. Обратитесь к администратору.";
|
||||||
|
|
||||||
|
|
@ -42,12 +40,6 @@ export const resolveDemoUser = (email, roleHint) => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isRoleSwitchEntryEmail = (email) =>
|
|
||||||
String(email || "").trim().toLowerCase() === ROLE_SWITCH_ENTRY_EMAIL;
|
|
||||||
|
|
||||||
export const resolveLoginEmail = (isDemoMode, email) =>
|
|
||||||
isDemoMode ? DEMO_LOGIN_EMAIL : email;
|
|
||||||
|
|
||||||
export const mapProfileToAuthUser = (profile) => {
|
export const mapProfileToAuthUser = (profile) => {
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -122,7 +114,7 @@ export const AuthProvider = ({ children }) => {
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
const requestOtp = async ({ email, roleHint }) => {
|
const requestOtp = async ({ email, roleHint = "manager" }) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setPendingEmail(email);
|
setPendingEmail(email);
|
||||||
setAuthError("");
|
setAuthError("");
|
||||||
|
|
@ -168,7 +160,7 @@ export const AuthProvider = ({ children }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (otp !== "000000") {
|
if (otp !== "000000") {
|
||||||
throw new Error("Для локального входа используйте код 000000");
|
throw new Error("Неверный код подтверждения");
|
||||||
}
|
}
|
||||||
|
|
||||||
const roleHint = localStorage.getItem("construction-auth-role-hint");
|
const roleHint = localStorage.getItem("construction-auth-role-hint");
|
||||||
|
|
@ -194,15 +186,6 @@ export const AuthProvider = ({ children }) => {
|
||||||
setAuthError("");
|
setAuthError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const signInWithRole = async (roleHint) => {
|
|
||||||
const localUser = resolveDemoUser("", roleHint);
|
|
||||||
setUser(localUser);
|
|
||||||
setPendingEmail(ROLE_SWITCH_ENTRY_EMAIL);
|
|
||||||
setIsOtpSent(false);
|
|
||||||
setAuthError("");
|
|
||||||
return { success: true, user: localUser };
|
|
||||||
};
|
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
user,
|
user,
|
||||||
pendingEmail,
|
pendingEmail,
|
||||||
|
|
@ -212,7 +195,6 @@ export const AuthProvider = ({ children }) => {
|
||||||
isDemoMode: !hasSupabaseConfig,
|
isDemoMode: !hasSupabaseConfig,
|
||||||
requestOtp,
|
requestOtp,
|
||||||
verifyOtp,
|
verifyOtp,
|
||||||
signInWithRole,
|
|
||||||
signOut,
|
signOut,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,11 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
DEMO_LOGIN_EMAIL,
|
|
||||||
ROLE_SWITCH_ENTRY_EMAIL,
|
|
||||||
UNKNOWN_EMAIL_ERROR,
|
UNKNOWN_EMAIL_ERROR,
|
||||||
buildOtpRequestPayload,
|
buildOtpRequestPayload,
|
||||||
isRoleSwitchEntryEmail,
|
|
||||||
mapProfileToAuthUser,
|
mapProfileToAuthUser,
|
||||||
mapSessionUserToAuthUser,
|
mapSessionUserToAuthUser,
|
||||||
normalizeOtpError,
|
normalizeOtpError,
|
||||||
resolveDemoUser,
|
resolveDemoUser,
|
||||||
resolveLoginEmail,
|
|
||||||
} from "./AuthContext";
|
} from "./AuthContext";
|
||||||
|
|
||||||
describe("resolveDemoUser", () => {
|
describe("resolveDemoUser", () => {
|
||||||
|
|
@ -19,26 +15,11 @@ describe("resolveDemoUser", () => {
|
||||||
expect(user.role).toBe("driver");
|
expect(user.role).toBe("driver");
|
||||||
expect(user.email).toBe("driver@local");
|
expect(user.email).toBe("driver@local");
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
describe("resolveLoginEmail", () => {
|
it("falls back to the matching email when no role hint is provided", () => {
|
||||||
it("forces a single shared email in demo mode", () => {
|
const user = resolveDemoUser("admin@local");
|
||||||
expect(resolveLoginEmail(true, "manager@local")).toBe(DEMO_LOGIN_EMAIL);
|
|
||||||
expect(resolveLoginEmail(true, "driver@local")).toBe(DEMO_LOGIN_EMAIL);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the entered email outside demo mode", () => {
|
expect(user.email).toBe("admin@local");
|
||||||
expect(resolveLoginEmail(false, "user@company.ru")).toBe("user@company.ru");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("isRoleSwitchEntryEmail", () => {
|
|
||||||
it("recognizes the special service email regardless of case and spacing", () => {
|
|
||||||
expect(isRoleSwitchEntryEmail(` ${ROLE_SWITCH_ENTRY_EMAIL.toUpperCase()} `)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores regular emails", () => {
|
|
||||||
expect(isRoleSwitchEntryEmail("user@company.ru")).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -86,7 +86,7 @@ const baseDemoOrders = [
|
||||||
customer: {
|
customer: {
|
||||||
name: "Мария Волкова",
|
name: "Мария Волкова",
|
||||||
phone: "+7 978 000-12-31",
|
phone: "+7 978 000-12-31",
|
||||||
messenger: "Телеграм",
|
messenger: "СМС",
|
||||||
address: "Симферополь, ул. Тургенева, 18",
|
address: "Симферополь, ул. Тургенева, 18",
|
||||||
},
|
},
|
||||||
status: "Ожидает согласования доставки",
|
status: "Ожидает согласования доставки",
|
||||||
|
|
@ -158,14 +158,14 @@ const baseDemoOrders = [
|
||||||
{
|
{
|
||||||
id: "c-1",
|
id: "c-1",
|
||||||
sender: "bot",
|
sender: "bot",
|
||||||
channel: "Телеграм",
|
channel: "СМС",
|
||||||
text: "Заказ CD-240031 готов. Выберите дату и половину дня доставки.",
|
text: "Заказ CD-240031 готов. Выберите дату и половину дня доставки.",
|
||||||
sentAt: "2026-03-12T09:42:00Z",
|
sentAt: "2026-03-12T09:42:00Z",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "c-2",
|
id: "c-2",
|
||||||
sender: "client",
|
sender: "client",
|
||||||
channel: "Телеграм",
|
channel: "СМС",
|
||||||
text: "Подтвержу позже, вернусь после 16:00.",
|
text: "Подтвержу позже, вернусь после 16:00.",
|
||||||
sentAt: "2026-03-12T10:05:00Z",
|
sentAt: "2026-03-12T10:05:00Z",
|
||||||
},
|
},
|
||||||
|
|
@ -196,7 +196,7 @@ const baseDemoOrders = [
|
||||||
customer: {
|
customer: {
|
||||||
name: "Александр Савин",
|
name: "Александр Савин",
|
||||||
phone: "+7 978 000-12-32",
|
phone: "+7 978 000-12-32",
|
||||||
messenger: "ВКонтакте",
|
messenger: "Эл. почта",
|
||||||
address: "Ялта, ул. Чехова, 9",
|
address: "Ялта, ул. Чехова, 9",
|
||||||
},
|
},
|
||||||
status: "Готов к отгрузке",
|
status: "Готов к отгрузке",
|
||||||
|
|
@ -255,7 +255,7 @@ const baseDemoOrders = [
|
||||||
customer: {
|
customer: {
|
||||||
name: "Екатерина Тарасова",
|
name: "Екатерина Тарасова",
|
||||||
phone: "+7 978 000-12-33",
|
phone: "+7 978 000-12-33",
|
||||||
messenger: "Макс",
|
messenger: "СМС",
|
||||||
address: "Севастополь, пр. Октябрьской Революции, 51",
|
address: "Севастополь, пр. Октябрьской Революции, 51",
|
||||||
},
|
},
|
||||||
status: "Проблема доставки",
|
status: "Проблема доставки",
|
||||||
|
|
@ -299,7 +299,7 @@ const baseDemoOrders = [
|
||||||
{
|
{
|
||||||
id: "c-3",
|
id: "c-3",
|
||||||
sender: "bot",
|
sender: "bot",
|
||||||
channel: "Макс",
|
channel: "СМС",
|
||||||
text: "Напоминаем о необходимости выбрать дату доставки.",
|
text: "Напоминаем о необходимости выбрать дату доставки.",
|
||||||
sentAt: "2026-03-12T06:35:00Z",
|
sentAt: "2026-03-12T06:35:00Z",
|
||||||
},
|
},
|
||||||
|
|
@ -406,7 +406,7 @@ const baseDemoOrders = [
|
||||||
customer: {
|
customer: {
|
||||||
name: "Ирина Лебедева",
|
name: "Ирина Лебедева",
|
||||||
phone: "+7 978 000-12-36",
|
phone: "+7 978 000-12-36",
|
||||||
messenger: "Телеграм",
|
messenger: "СМС",
|
||||||
address: "Симферополь, ул. Киевская, 112",
|
address: "Симферополь, ул. Киевская, 112",
|
||||||
},
|
},
|
||||||
status: "Назначен водитель",
|
status: "Назначен водитель",
|
||||||
|
|
@ -451,7 +451,7 @@ const baseDemoOrders = [
|
||||||
{
|
{
|
||||||
id: "c-6",
|
id: "c-6",
|
||||||
sender: "bot",
|
sender: "bot",
|
||||||
channel: "Телеграм",
|
channel: "СМС",
|
||||||
text: "Доставка подтверждена на 14 марта, первая половина дня.",
|
text: "Доставка подтверждена на 14 марта, первая половина дня.",
|
||||||
sentAt: "2026-03-13T15:22:00Z",
|
sentAt: "2026-03-13T15:22:00Z",
|
||||||
},
|
},
|
||||||
|
|
@ -623,30 +623,30 @@ const baseDemoOrders = [
|
||||||
];
|
];
|
||||||
|
|
||||||
const extraOrderSeeds = [
|
const extraOrderSeeds = [
|
||||||
{ suffix: 101, customerName: "Людмила Артемьева", status: "Новый", city: "Симферополь", item: "Шкаф распашной", messenger: "Телеграм", updatedAt: "2026-03-14T08:20:00Z" },
|
{ suffix: 101, customerName: "Людмила Артемьева", status: "Новый", city: "Симферополь", item: "Шкаф распашной", messenger: "СМС", updatedAt: "2026-03-14T08:20:00Z" },
|
||||||
{ suffix: 102, customerName: "Павел Карпов", status: "Новый", city: "Ялта", item: "Стол обеденный", messenger: "ВКонтакте", updatedAt: "2026-03-14T10:10:00Z" },
|
{ suffix: 102, customerName: "Павел Карпов", status: "Новый", city: "Ялта", item: "Стол обеденный", messenger: "Эл. почта", updatedAt: "2026-03-14T10:10:00Z" },
|
||||||
{ suffix: 103, customerName: "Алёна Беспалова", status: "Требует уточнения", city: "Евпатория", item: "Тумба ТВ", messenger: "СМС", updatedAt: "2026-03-14T06:40:00Z" },
|
{ suffix: 103, customerName: "Алёна Беспалова", status: "Требует уточнения", city: "Евпатория", item: "Тумба ТВ", messenger: "СМС", updatedAt: "2026-03-14T06:40:00Z" },
|
||||||
{ suffix: 104, customerName: "Георгий Храмов", status: "Требует уточнения", city: "Севастополь", item: "Комод", messenger: "Макс", updatedAt: "2026-03-13T17:50:00Z" },
|
{ suffix: 104, customerName: "Георгий Храмов", status: "Требует уточнения", city: "Севастополь", item: "Комод", messenger: "СМС", updatedAt: "2026-03-13T17:50:00Z" },
|
||||||
{ suffix: 105, customerName: "Валерия Фролова", status: "Подтверждён менеджером", city: "Симферополь", item: "Кухонный фасад", messenger: "Телеграм", updatedAt: "2026-03-14T11:35:00Z" },
|
{ suffix: 105, customerName: "Валерия Фролова", status: "Подтверждён менеджером", city: "Симферополь", item: "Кухонный фасад", messenger: "СМС", updatedAt: "2026-03-14T11:35:00Z" },
|
||||||
{ suffix: 106, customerName: "Иван Мирошниченко", status: "Подтверждён менеджером", city: "Алушта", item: "Шкаф-купе", messenger: "Эл. почта", updatedAt: "2026-03-14T09:25:00Z" },
|
{ suffix: 106, customerName: "Иван Мирошниченко", status: "Подтверждён менеджером", city: "Алушта", item: "Шкаф-купе", messenger: "Эл. почта", updatedAt: "2026-03-14T09:25:00Z" },
|
||||||
{ suffix: 107, customerName: "Марина Ермакова", status: "В очереди производства", city: "Симферополь", item: "Столешница", messenger: "Телеграм", updatedAt: "2026-03-13T12:10:00Z" },
|
{ suffix: 107, customerName: "Марина Ермакова", status: "В очереди производства", city: "Симферополь", item: "Столешница", messenger: "СМС", updatedAt: "2026-03-13T12:10:00Z" },
|
||||||
{ suffix: 108, customerName: "Руслан Гладков", status: "В очереди производства", city: "Ялта", item: "Гардеробная секция", messenger: "ВКонтакте", updatedAt: "2026-03-13T08:00:00Z" },
|
{ suffix: 108, customerName: "Руслан Гладков", status: "В очереди производства", city: "Ялта", item: "Гардеробная секция", messenger: "Эл. почта", updatedAt: "2026-03-13T08:00:00Z" },
|
||||||
{ suffix: 109, customerName: "Светлана Коваль", status: "В очереди производства", city: "Севастополь", item: "Дверь межкомнатная", messenger: "СМС", updatedAt: "2026-03-12T13:45:00Z" },
|
{ suffix: 109, customerName: "Светлана Коваль", status: "В очереди производства", city: "Севастополь", item: "Дверь межкомнатная", messenger: "СМС", updatedAt: "2026-03-12T13:45:00Z" },
|
||||||
{ suffix: 110, customerName: "Михаил Орлов", status: "В производстве", city: "Симферополь", item: "Кухня линейная", messenger: "Телеграм", updatedAt: "2026-03-13T09:30:00Z" },
|
{ suffix: 110, customerName: "Михаил Орлов", status: "В производстве", city: "Симферополь", item: "Кухня линейная", messenger: "СМС", updatedAt: "2026-03-13T09:30:00Z" },
|
||||||
{ suffix: 111, customerName: "Татьяна Шубина", status: "В производстве", city: "Ялта", item: "Стеллаж", messenger: "Макс", updatedAt: "2026-03-12T15:20:00Z" },
|
{ suffix: 111, customerName: "Татьяна Шубина", status: "В производстве", city: "Ялта", item: "Стеллаж", messenger: "СМС", updatedAt: "2026-03-12T15:20:00Z" },
|
||||||
{ suffix: 112, customerName: "Андрей Беляев", status: "В производстве", city: "Евпатория", item: "Фасады МДФ", messenger: "Эл. почта", updatedAt: "2026-03-14T07:55:00Z" },
|
{ suffix: 112, customerName: "Андрей Беляев", status: "В производстве", city: "Евпатория", item: "Фасады МДФ", messenger: "Эл. почта", updatedAt: "2026-03-14T07:55:00Z" },
|
||||||
{ suffix: 113, customerName: "Елена Бондарь", status: "Готов к отгрузке", city: "Симферополь", item: "Пенал для кухни", messenger: "Телеграм", updatedAt: "2026-03-14T05:15:00Z" },
|
{ suffix: 113, customerName: "Елена Бондарь", status: "Готов к отгрузке", city: "Симферополь", item: "Пенал для кухни", messenger: "СМС", updatedAt: "2026-03-14T05:15:00Z" },
|
||||||
{ suffix: 114, customerName: "Кирилл Нестеров", status: "Готов к отгрузке", city: "Ялта", item: "Стол письменный", messenger: "СМС", updatedAt: "2026-03-14T09:45:00Z" },
|
{ suffix: 114, customerName: "Кирилл Нестеров", status: "Готов к отгрузке", city: "Ялта", item: "Стол письменный", messenger: "СМС", updatedAt: "2026-03-14T09:45:00Z" },
|
||||||
{ suffix: 115, customerName: "Наталья Зотова", status: "Готов к отгрузке", city: "Севастополь", item: "Шкаф угловой", messenger: "ВКонтакте", updatedAt: "2026-03-13T18:05:00Z" },
|
{ suffix: 115, customerName: "Наталья Зотова", status: "Готов к отгрузке", city: "Севастополь", item: "Шкаф угловой", messenger: "Эл. почта", updatedAt: "2026-03-13T18:05:00Z" },
|
||||||
{ suffix: 116, customerName: "Константин Матвеев", status: "Ожидает согласования доставки", city: "Симферополь", item: "Комод высокий", messenger: "Телеграм", updatedAt: "2026-03-14T08:05:00Z" },
|
{ suffix: 116, customerName: "Константин Матвеев", status: "Ожидает согласования доставки", city: "Симферополь", item: "Комод высокий", messenger: "СМС", updatedAt: "2026-03-14T08:05:00Z" },
|
||||||
{ suffix: 117, customerName: "Лариса Шевцова", status: "Ожидает согласования доставки", city: "Ялта", item: "Стеллаж модульный", messenger: "Макс", updatedAt: "2026-03-13T06:50:00Z" },
|
{ suffix: 117, customerName: "Лариса Шевцова", status: "Ожидает согласования доставки", city: "Ялта", item: "Стеллаж модульный", messenger: "СМС", updatedAt: "2026-03-13T06:50:00Z" },
|
||||||
{ suffix: 118, customerName: "Евгений Филимонов", status: "Ожидает согласования доставки", city: "Севастополь", item: "Тумба под мойку", messenger: "СМС", updatedAt: "2026-03-12T08:15:00Z" },
|
{ suffix: 118, customerName: "Евгений Филимонов", status: "Ожидает согласования доставки", city: "Севастополь", item: "Тумба под мойку", messenger: "СМС", updatedAt: "2026-03-12T08:15:00Z" },
|
||||||
{ suffix: 119, customerName: "Диана Рябова", status: "Доставка согласована", city: "Симферополь", item: "Навесной шкаф", messenger: "Телеграм", updatedAt: "2026-03-14T11:00:00Z" },
|
{ suffix: 119, customerName: "Диана Рябова", status: "Доставка согласована", city: "Симферополь", item: "Навесной шкаф", messenger: "СМС", updatedAt: "2026-03-14T11:00:00Z" },
|
||||||
{ suffix: 120, customerName: "Олег Вишневский", status: "Доставка согласована", city: "Алушта", item: "Стол раскладной", messenger: "Эл. почта", updatedAt: "2026-03-14T04:20:00Z" },
|
{ suffix: 120, customerName: "Олег Вишневский", status: "Доставка согласована", city: "Алушта", item: "Стол раскладной", messenger: "Эл. почта", updatedAt: "2026-03-14T04:20:00Z" },
|
||||||
{ suffix: 121, customerName: "Полина Исаева", status: "Назначен водитель", city: "Симферополь", item: "Кровать", messenger: "Телеграм", updatedAt: "2026-03-14T10:40:00Z" },
|
{ suffix: 121, customerName: "Полина Исаева", status: "Назначен водитель", city: "Симферополь", item: "Кровать", messenger: "СМС", updatedAt: "2026-03-14T10:40:00Z" },
|
||||||
{ suffix: 122, customerName: "Роман Щукин", status: "Назначен водитель", city: "Ялта", item: "Прихожая", messenger: "ВКонтакте", updatedAt: "2026-03-14T09:05:00Z" },
|
{ suffix: 122, customerName: "Роман Щукин", status: "Назначен водитель", city: "Ялта", item: "Прихожая", messenger: "Эл. почта", updatedAt: "2026-03-14T09:05:00Z" },
|
||||||
{ suffix: 123, customerName: "Юлия Баранова", status: "Загружен", city: "Севастополь", item: "Шкаф-пенал", messenger: "СМС", updatedAt: "2026-03-14T07:30:00Z" },
|
{ suffix: 123, customerName: "Юлия Баранова", status: "Загружен", city: "Севастополь", item: "Шкаф-пенал", messenger: "СМС", updatedAt: "2026-03-14T07:30:00Z" },
|
||||||
{ suffix: 124, customerName: "Виктор Громыко", status: "В пути", city: "Симферополь", item: "Гарнитур в прихожую", messenger: "Телеграм", updatedAt: "2026-03-14T11:20:00Z" },
|
{ suffix: 124, customerName: "Виктор Громыко", status: "В пути", city: "Симферополь", item: "Гарнитур в прихожую", messenger: "СМС", updatedAt: "2026-03-14T11:20:00Z" },
|
||||||
{ suffix: 125, customerName: "Инна Самойлова", status: "Доставлен", city: "Евпатория", item: "Шкаф в ванную", messenger: "Эл. почта", updatedAt: "2026-03-14T12:05:00Z" },
|
{ suffix: 125, customerName: "Инна Самойлова", status: "Доставлен", city: "Евпатория", item: "Шкаф в ванную", messenger: "Эл. почта", updatedAt: "2026-03-14T12:05:00Z" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,16 @@ import { ThemeToggle } from "../components/UI/ThemeToggle";
|
||||||
export const AppShell = ({
|
export const AppShell = ({
|
||||||
user,
|
user,
|
||||||
onSignOut,
|
onSignOut,
|
||||||
|
onOpenGuide,
|
||||||
|
isGuideOpen = false,
|
||||||
navItems,
|
navItems,
|
||||||
activeSection,
|
activeSection,
|
||||||
onSectionChange,
|
onSectionChange,
|
||||||
sectionMeta,
|
sectionMeta,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
|
const shouldShowMobileNav = !isGuideOpen && navItems.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
<div className="min-h-screen px-3 py-4 sm:px-4 md:px-6 md:py-8">
|
||||||
<div className="mx-auto max-w-[1540px] space-y-4 xl:grid xl:grid-cols-[220px_1fr] xl:gap-8 xl:space-y-0">
|
<div className="mx-auto max-w-[1540px] space-y-4 xl:grid xl:grid-cols-[220px_1fr] xl:gap-8 xl:space-y-0">
|
||||||
|
|
@ -45,6 +49,11 @@ export const AppShell = ({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-auto">
|
<div className="mt-auto">
|
||||||
|
{onOpenGuide ? (
|
||||||
|
<Button variant="ghost" className="mb-2 w-full justify-start" onClick={onOpenGuide}>
|
||||||
|
{isGuideOpen ? "К рабочей области" : "Справка"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
|
<Button variant="ghost" className="w-full justify-start" onClick={onSignOut}>
|
||||||
Выйти
|
Выйти
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -64,6 +73,11 @@ export const AppShell = ({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
|
{onOpenGuide ? (
|
||||||
|
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
|
||||||
|
{isGuideOpen ? "Назад" : "?"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
<Button size="sm" variant="ghost" onClick={onSignOut}>
|
<Button size="sm" variant="ghost" onClick={onSignOut}>
|
||||||
Выйти
|
Выйти
|
||||||
|
|
@ -90,6 +104,11 @@ export const AppShell = ({
|
||||||
<div className="text-sm font-medium">{user.name}</div>
|
<div className="text-sm font-medium">{user.name}</div>
|
||||||
<div className="text-sm text-[var(--color-text-muted)]">{ROLE_LABELS[user.role]}</div>
|
<div className="text-sm text-[var(--color-text-muted)]">{ROLE_LABELS[user.role]}</div>
|
||||||
</div>
|
</div>
|
||||||
|
{onOpenGuide ? (
|
||||||
|
<Button size="sm" variant="ghost" onClick={onOpenGuide} aria-label="Справка">
|
||||||
|
{isGuideOpen ? "Назад" : "?"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -99,7 +118,8 @@ export const AppShell = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--color-border)] bg-[rgba(244,247,245,0.94)] px-3 py-3 backdrop-blur xl:hidden">
|
{shouldShowMobileNav ? (
|
||||||
|
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-3 backdrop-blur xl:hidden">
|
||||||
<div className="mx-auto flex max-w-[1540px] gap-2 overflow-x-auto">
|
<div className="mx-auto flex max-w-[1540px] gap-2 overflow-x-auto">
|
||||||
{navItems.map((item) => (
|
{navItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -119,6 +139,7 @@ export const AppShell = ({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ describe("AppShell", () => {
|
||||||
]}
|
]}
|
||||||
activeSection="orders"
|
activeSection="orders"
|
||||||
onSectionChange={() => {}}
|
onSectionChange={() => {}}
|
||||||
|
onOpenGuide={() => {}}
|
||||||
sectionMeta={{ label: "Заказы", description: "Рабочая область заказов" }}
|
sectionMeta={{ label: "Заказы", description: "Рабочая область заказов" }}
|
||||||
>
|
>
|
||||||
<div>content</div>
|
<div>content</div>
|
||||||
|
|
@ -32,5 +33,44 @@ describe("AppShell", () => {
|
||||||
expect(markup).toContain("min-w-0");
|
expect(markup).toContain("min-w-0");
|
||||||
expect(markup).toContain("Рабочая область");
|
expect(markup).toContain("Рабочая область");
|
||||||
expect(markup).toContain("Заказы");
|
expect(markup).toContain("Заказы");
|
||||||
|
expect(markup).toContain("aria-label=\"Справка\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides bottom navigation and shows a back action when guide is open", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AppShell
|
||||||
|
user={{ name: "Анна Мельник", role: "manager" }}
|
||||||
|
onSignOut={() => {}}
|
||||||
|
onOpenGuide={() => {}}
|
||||||
|
isGuideOpen={true}
|
||||||
|
navItems={[{ key: "orders", label: "Заказы", badge: "7" }]}
|
||||||
|
activeSection="orders"
|
||||||
|
onSectionChange={() => {}}
|
||||||
|
sectionMeta={{ label: "Справка", description: "Описание продукта" }}
|
||||||
|
>
|
||||||
|
<div>content</div>
|
||||||
|
</AppShell>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).toContain("Назад");
|
||||||
|
expect(markup).not.toContain("fixed inset-x-0 bottom-0");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides bottom navigation when the role has only one section", () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<AppShell
|
||||||
|
user={{ name: "Анна Мельник", role: "manager" }}
|
||||||
|
onSignOut={() => {}}
|
||||||
|
onOpenGuide={() => {}}
|
||||||
|
navItems={[{ key: "orders", label: "Заказы", badge: "0" }]}
|
||||||
|
activeSection="orders"
|
||||||
|
onSectionChange={() => {}}
|
||||||
|
sectionMeta={{ label: "Заказы" }}
|
||||||
|
>
|
||||||
|
<div>content</div>
|
||||||
|
</AppShell>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(markup).not.toContain("fixed inset-x-0 bottom-0");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,19 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { DRIVER_STATUSES, LOGISTICS_STATUSES, getStatusTone } from "../constants/deliveryWorkflow";
|
import { DRIVER_STATUSES } from "../constants/deliveryWorkflow";
|
||||||
import { DriverDeliveryDetail } from "../components/driver/DriverDeliveryDetail";
|
import { DriverDeliveryDetail } from "../components/driver/DriverDeliveryDetail";
|
||||||
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
import { DriverDeliveryPlanner } from "../components/driver/DriverDeliveryPlanner";
|
||||||
|
import { DeliverySetDetailPanel } from "../components/logistics/DeliverySetDetailPanel";
|
||||||
|
import { LogisticsReadinessBoard } from "../components/logistics/LogisticsReadinessBoard";
|
||||||
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
|
import { OrderDetailPanel } from "../components/orders/OrderDetailPanel";
|
||||||
import { OrderFilters } from "../components/orders/OrderFilters";
|
|
||||||
import { OrdersTable } from "../components/orders/OrdersTable";
|
import { OrdersTable } from "../components/orders/OrdersTable";
|
||||||
import { Badge } from "../components/UI/Badge";
|
|
||||||
import { Button } from "../components/UI/Button";
|
import { Button } from "../components/UI/Button";
|
||||||
import { Modal } from "../components/UI/Modal";
|
import { Modal } from "../components/UI/Modal";
|
||||||
import { Panel } from "../components/UI/Panel";
|
import { Panel } from "../components/UI/Panel";
|
||||||
|
import { ProductGuidePanel } from "../components/dashboard/ProductGuidePanel";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { useOrders } from "../hooks/useOrders";
|
import { useOrders } from "../hooks/useOrders";
|
||||||
import { AppShell } from "../layouts/AppShell";
|
import { AppShell } from "../layouts/AppShell";
|
||||||
import { getDeliveryCity, getDeliveryDay, getDeliveryHalfDay } from "../services/driverDeliveries";
|
|
||||||
import { RoleWorkspacePanel } from "../components/dashboard/RoleWorkspacePanel";
|
|
||||||
|
|
||||||
const ROLE_SECTION = {
|
const ROLE_SECTION = {
|
||||||
manager: {
|
manager: {
|
||||||
|
|
@ -34,67 +33,14 @@ const ROLE_SECTION = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const addDays = (date, days) => {
|
|
||||||
const next = new Date(date);
|
|
||||||
next.setDate(next.getDate() + days);
|
|
||||||
return next;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateKey = (date) => date.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const buildLogisticsBuckets = (orders) => {
|
|
||||||
const todayKey = formatDateKey(new Date());
|
|
||||||
const tomorrowKey = formatDateKey(addDays(new Date(), 1));
|
|
||||||
const dayAfterTomorrowKey = formatDateKey(addDays(new Date(), 2));
|
|
||||||
|
|
||||||
const buckets = {
|
|
||||||
today: [],
|
|
||||||
tomorrow: [],
|
|
||||||
dayAfterTomorrow: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const order of orders) {
|
|
||||||
const deliveryDay = getDeliveryDay(order);
|
|
||||||
if (deliveryDay === todayKey) {
|
|
||||||
buckets.today.push(order);
|
|
||||||
} else if (deliveryDay === tomorrowKey) {
|
|
||||||
buckets.tomorrow.push(order);
|
|
||||||
} else if (deliveryDay === dayAfterTomorrowKey) {
|
|
||||||
buckets.dayAfterTomorrow.push(order);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return buckets;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LogisticsOrderCard = ({ order, onOpenOrder }) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="rounded-[24px] border border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-left transition hover:bg-[var(--color-accent-soft)]"
|
|
||||||
onClick={() => onOpenOrder(order.id)}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{order.orderNumber}</div>
|
|
||||||
<div className="mt-1 text-sm text-[var(--color-text-muted)]">
|
|
||||||
{order.customer.name} · {getDeliveryCity(order)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Badge tone={getStatusTone(order.status)}>{order.status}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="mt-3 grid gap-2 text-sm text-[var(--color-text-muted)] md:grid-cols-2">
|
|
||||||
<div>{getDeliveryDay(order)}</div>
|
|
||||||
<div>{getDeliveryHalfDay(order)}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
|
|
||||||
export const DashboardPage = () => {
|
export const DashboardPage = () => {
|
||||||
const { user, signOut } = useAuth();
|
const { user, signOut } = useAuth();
|
||||||
const userRole = user?.role;
|
const userRole = user?.role;
|
||||||
const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager;
|
const section = ROLE_SECTION[userRole] || ROLE_SECTION.manager;
|
||||||
const [activeSection, setActiveSection] = React.useState(section.key);
|
const [activeSection, setActiveSection] = React.useState(section.key);
|
||||||
const [isOrderModalOpen, setIsOrderModalOpen] = React.useState(false);
|
const [isOrderModalOpen, setIsOrderModalOpen] = React.useState(false);
|
||||||
|
const [isDeliverySetModalOpen, setIsDeliverySetModalOpen] = React.useState(false);
|
||||||
|
const [selectedDeliverySet, setSelectedDeliverySet] = React.useState(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
orders,
|
orders,
|
||||||
|
|
@ -106,9 +52,9 @@ export const DashboardPage = () => {
|
||||||
setFilters,
|
setFilters,
|
||||||
updateStatus,
|
updateStatus,
|
||||||
users,
|
users,
|
||||||
isSupabaseBacked,
|
|
||||||
isLoading,
|
isLoading,
|
||||||
loadError,
|
loadError,
|
||||||
|
deliverySetBuckets,
|
||||||
} = useOrders(user);
|
} = useOrders(user);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
|
|
@ -121,9 +67,10 @@ export const DashboardPage = () => {
|
||||||
}
|
}
|
||||||
}, [allOrders, selectedOrderId, setSelectedOrderId]);
|
}, [allOrders, selectedOrderId, setSelectedOrderId]);
|
||||||
|
|
||||||
if (!user) {
|
const openDeliverySetModal = React.useCallback((deliverySet) => {
|
||||||
return <Navigate to="/login" replace />;
|
setSelectedDeliverySet(deliverySet);
|
||||||
}
|
setIsDeliverySetModalOpen(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
|
|
@ -133,84 +80,48 @@ export const DashboardPage = () => {
|
||||||
badge: String(allOrders.length || orders.length || 0),
|
badge: String(allOrders.length || orders.length || 0),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const guideSectionMeta = {
|
||||||
|
key: "guide",
|
||||||
|
label: "Справка",
|
||||||
|
description: "Карта продукта, роли, сценарии и частые вопросы.",
|
||||||
|
};
|
||||||
|
const activeSectionMeta = activeSection === "guide" ? guideSectionMeta : navItems[0];
|
||||||
|
const isGuideOpen = activeSection === "guide";
|
||||||
|
|
||||||
const openOrderModal = (orderId) => {
|
const openOrderModal = (orderId) => {
|
||||||
setSelectedOrderId(orderId);
|
setSelectedOrderId(orderId);
|
||||||
setIsOrderModalOpen(true);
|
setIsOrderModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const logisticsOrders = React.useMemo(
|
|
||||||
() => allOrders.filter((order) => LOGISTICS_STATUSES.includes(order.status)),
|
|
||||||
[allOrders],
|
|
||||||
);
|
|
||||||
const logisticsBuckets = React.useMemo(
|
|
||||||
() => buildLogisticsBuckets(logisticsOrders),
|
|
||||||
[logisticsOrders],
|
|
||||||
);
|
|
||||||
const driverOrders = React.useMemo(
|
const driverOrders = React.useMemo(
|
||||||
() => allOrders.filter((order) => DRIVER_STATUSES.includes(order.status)),
|
() => allOrders.filter((order) => DRIVER_STATUSES.includes(order.status)),
|
||||||
[allOrders],
|
[allOrders],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
const renderManagerWorkspace = () => (
|
const renderManagerWorkspace = () => (
|
||||||
<div className="space-y-6 xl:space-y-8">
|
<div className="space-y-6 xl:space-y-8">
|
||||||
<RoleWorkspacePanel role="manager" />
|
<OrdersTable
|
||||||
<Panel className="space-y-3 p-5">
|
orders={orders}
|
||||||
<div>
|
selectedOrderId={selectedOrderId}
|
||||||
<h3 className="text-lg font-semibold">Реестр заказов</h3>
|
onOpenOrder={openOrderModal}
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
users={users}
|
||||||
Поиск по номеру, клиенту и телефону. Только доставочный контур без внутренних режимов.
|
filters={filters}
|
||||||
</p>
|
setFilters={setFilters}
|
||||||
</div>
|
/>
|
||||||
<OrderFilters filters={filters} setFilters={setFilters} />
|
|
||||||
</Panel>
|
|
||||||
<OrdersTable orders={orders} selectedOrderId={selectedOrderId} onOpenOrder={openOrderModal} users={users} />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderLogisticsWorkspace = () => (
|
const renderLogisticsWorkspace = () => (
|
||||||
<div className="space-y-6 xl:space-y-8">
|
<div className="space-y-6 xl:space-y-8">
|
||||||
<RoleWorkspacePanel role="logistician" />
|
<LogisticsReadinessBoard deliverySetBuckets={deliverySetBuckets} onSelectSet={openDeliverySetModal} />
|
||||||
<div className="grid gap-4 xl:grid-cols-3">
|
|
||||||
{[
|
|
||||||
{ key: "today", title: "Сегодня", orders: logisticsBuckets.today },
|
|
||||||
{ key: "tomorrow", title: "Завтра", orders: logisticsBuckets.tomorrow },
|
|
||||||
{ key: "dayAfterTomorrow", title: "Послезавтра", orders: logisticsBuckets.dayAfterTomorrow },
|
|
||||||
].map((bucket) => (
|
|
||||||
<Panel key={bucket.key} className="space-y-4 p-5">
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">{bucket.title}</h3>
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
|
||||||
{bucket.orders.length} {bucket.orders.length === 1 ? "доставка" : "доставки"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge tone="neutral">{bucket.orders.length}</Badge>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-3">
|
|
||||||
{bucket.orders.length ? (
|
|
||||||
bucket.orders.map((order) => (
|
|
||||||
<LogisticsOrderCard key={order.id} order={order} onOpenOrder={openOrderModal} />
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">Нет доставок в этом окне.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<Panel className="p-5">
|
|
||||||
<h3 className="text-lg font-semibold">Заказы в логистике</h3>
|
|
||||||
<p className="mt-2 text-sm text-[var(--color-text-muted)]">
|
|
||||||
Здесь видны только заказы, относящиеся к доставке. Никаких бот-панелей, справочников и внутренних каналов.
|
|
||||||
</p>
|
|
||||||
</Panel>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderDriverWorkspace = () => (
|
const renderDriverWorkspace = () => (
|
||||||
<div className="space-y-6 xl:space-y-8">
|
<div className="space-y-6 xl:space-y-8">
|
||||||
<RoleWorkspacePanel role="driver" />
|
|
||||||
<DriverDeliveryPlanner
|
<DriverDeliveryPlanner
|
||||||
orders={driverOrders}
|
orders={driverOrders}
|
||||||
onOpenOrder={openOrderModal}
|
onOpenOrder={openOrderModal}
|
||||||
|
|
@ -220,6 +131,10 @@ export const DashboardPage = () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderActiveSection = () => {
|
const renderActiveSection = () => {
|
||||||
|
if (activeSection === "guide") {
|
||||||
|
return <ProductGuidePanel />;
|
||||||
|
}
|
||||||
|
|
||||||
if (userRole === "driver") {
|
if (userRole === "driver") {
|
||||||
return renderDriverWorkspace();
|
return renderDriverWorkspace();
|
||||||
}
|
}
|
||||||
|
|
@ -235,24 +150,21 @@ export const DashboardPage = () => {
|
||||||
<AppShell
|
<AppShell
|
||||||
user={user}
|
user={user}
|
||||||
onSignOut={signOut}
|
onSignOut={signOut}
|
||||||
|
onOpenGuide={() => setActiveSection((current) => (current === "guide" ? section.key : "guide"))}
|
||||||
|
isGuideOpen={isGuideOpen}
|
||||||
navItems={navItems}
|
navItems={navItems}
|
||||||
activeSection={activeSection}
|
activeSection={activeSection}
|
||||||
onSectionChange={setActiveSection}
|
onSectionChange={setActiveSection}
|
||||||
sectionMeta={navItems[0]}
|
sectionMeta={activeSectionMeta}
|
||||||
>
|
>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
||||||
Загружаем данные из Supabase...
|
Загружаем данные...
|
||||||
</Panel>
|
|
||||||
) : null}
|
|
||||||
{isSupabaseBacked ? (
|
|
||||||
<Panel className="border border-dashed border-[var(--color-border)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-text-muted)]">
|
|
||||||
Данные загружены из Supabase, живой контур активен.
|
|
||||||
</Panel>
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
{loadError ? (
|
{loadError ? (
|
||||||
<Panel className="border border-dashed border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-danger)]">
|
<Panel className="border border-dashed border-[var(--color-danger)] bg-[var(--color-surface-strong)] p-4 text-sm text-[var(--color-danger)]">
|
||||||
{loadError}
|
Не удалось загрузить данные. Обратитесь к администратору.
|
||||||
</Panel>
|
</Panel>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -285,7 +197,7 @@ export const DashboardPage = () => {
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-semibold">Карточка заказа</h3>
|
<h3 className="text-xl font-semibold">Карточка заказа</h3>
|
||||||
<p className="text-sm text-[var(--color-text-muted)]">
|
<p className="text-sm text-[var(--color-text-muted)]">
|
||||||
Просмотр без лишних внутренних режимов и командных панелей.
|
Основные данные заказа, клиента и доставки.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" onClick={() => setIsOrderModalOpen(false)}>
|
<Button variant="ghost" onClick={() => setIsOrderModalOpen(false)}>
|
||||||
|
|
@ -296,6 +208,41 @@ export const DashboardPage = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
isOpen={isDeliverySetModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDeliverySetModalOpen(false);
|
||||||
|
setSelectedDeliverySet(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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={() => {
|
||||||
|
setIsDeliverySetModalOpen(false);
|
||||||
|
setSelectedDeliverySet(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DeliverySetDetailPanel
|
||||||
|
deliverySet={selectedDeliverySet}
|
||||||
|
onClose={() => {
|
||||||
|
setIsDeliverySetModalOpen(false);
|
||||||
|
setSelectedDeliverySet(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,15 @@ vi.mock("../hooks/useOrders", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../layouts/AppShell", () => ({
|
vi.mock("../layouts/AppShell", () => ({
|
||||||
AppShell: ({ children }) => <div>{children}</div>,
|
AppShell: ({ children, navItems, onOpenGuide, isGuideOpen }) => (
|
||||||
|
<div>
|
||||||
|
<nav>{navItems.map((item) => <span key={item.key}>{item.label}</span>)}</nav>
|
||||||
|
<button type="button" onClick={onOpenGuide} aria-label="Справка">
|
||||||
|
{isGuideOpen ? "Назад" : "?"}
|
||||||
|
</button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const baseOrder = {
|
const baseOrder = {
|
||||||
|
|
@ -32,7 +40,7 @@ const baseOrder = {
|
||||||
name: "Мария Волкова",
|
name: "Мария Волкова",
|
||||||
phone: "+7 978 000-12-31",
|
phone: "+7 978 000-12-31",
|
||||||
address: "Симферополь, ул. Ленина, 10",
|
address: "Симферополь, ул. Ленина, 10",
|
||||||
messenger: "Телеграм",
|
messenger: "СМС",
|
||||||
items: ["Кухонный гарнитур | 1 комплект"],
|
items: ["Кухонный гарнитур | 1 комплект"],
|
||||||
},
|
},
|
||||||
items: ["Кухонный гарнитур | 1 комплект"],
|
items: ["Кухонный гарнитур | 1 комплект"],
|
||||||
|
|
@ -43,6 +51,18 @@ const baseOrder = {
|
||||||
deliverySlots: [],
|
deliverySlots: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const baseDeliverySet = {
|
||||||
|
key: "set-1",
|
||||||
|
name: "Набор Марии Волковой",
|
||||||
|
status: "ready_to_launch",
|
||||||
|
readyAt: "2026-04-15T08:00:00Z",
|
||||||
|
readyReason: "all_accepted",
|
||||||
|
sourceCustomerCity: "Симферополь",
|
||||||
|
orderCount: 1,
|
||||||
|
linkedBillTexts: "УН-00031",
|
||||||
|
orders: [baseOrder],
|
||||||
|
};
|
||||||
|
|
||||||
const mockOrdersState = {
|
const mockOrdersState = {
|
||||||
orders: [baseOrder],
|
orders: [baseOrder],
|
||||||
allOrders: [baseOrder],
|
allOrders: [baseOrder],
|
||||||
|
|
@ -80,9 +100,12 @@ const mockOrdersState = {
|
||||||
agingAlerts: [],
|
agingAlerts: [],
|
||||||
agingSummary: { warning: 0, critical: 0 },
|
agingSummary: { warning: 0, critical: 0 },
|
||||||
deliverySetBuckets: {
|
deliverySetBuckets: {
|
||||||
ready_to_launch: [baseOrder],
|
approaching: [],
|
||||||
waiting: [],
|
ready_to_launch: [baseDeliverySet],
|
||||||
problem: [],
|
awaiting_client: [],
|
||||||
|
manual_work: [],
|
||||||
|
agreed: [],
|
||||||
|
completed: [],
|
||||||
},
|
},
|
||||||
users: [
|
users: [
|
||||||
{ id: "u-manager", name: "Анна", role: "manager" },
|
{ id: "u-manager", name: "Анна", role: "manager" },
|
||||||
|
|
@ -114,6 +137,12 @@ describe("DashboardPage", () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Реестр заказов");
|
expect(markup).toContain("Реестр заказов");
|
||||||
|
expect(markup).toContain("Поиск по номеру, клиенту и телефону.");
|
||||||
|
expect(markup).toContain("aria-label=\"Справка\"");
|
||||||
|
expect(markup).not.toContain("<span>Справка</span>");
|
||||||
|
expect(markup).not.toContain("доставочный контур");
|
||||||
|
expect(markup).not.toContain("Поиск по заказу, клиенту и телефону");
|
||||||
|
expect(markup).not.toContain("Интерфейс показывает");
|
||||||
expect(markup).not.toContain("Производство");
|
expect(markup).not.toContain("Производство");
|
||||||
expect(markup).not.toContain("Администрирование");
|
expect(markup).not.toContain("Администрирование");
|
||||||
expect(markup).not.toContain("Справочники");
|
expect(markup).not.toContain("Справочники");
|
||||||
|
|
@ -139,10 +168,11 @@ describe("DashboardPage", () => {
|
||||||
</MemoryRouter>,
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(markup).toContain("Логист: рабочая панель");
|
expect(markup).toContain("Наборы доставки");
|
||||||
expect(markup).toContain("Сегодня");
|
expect(markup).toContain("Готово к запуску");
|
||||||
expect(markup).not.toContain("Управление ботами");
|
expect(markup).not.toContain("Управление ботами");
|
||||||
expect(markup).not.toContain("Логика каналов");
|
expect(markup).not.toContain("рабочая панель");
|
||||||
|
expect(markup).not.toContain("Сегодня");
|
||||||
expect(markup).not.toContain("Производство");
|
expect(markup).not.toContain("Производство");
|
||||||
expect(markup).not.toContain("Администрирование");
|
expect(markup).not.toContain("Администрирование");
|
||||||
expect(markup).not.toContain("Справочники");
|
expect(markup).not.toContain("Справочники");
|
||||||
|
|
|
||||||
|
|
@ -1,49 +1,18 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Navigate } from "react-router-dom";
|
import { Navigate } from "react-router-dom";
|
||||||
import { OtpLoginForm } from "../components/auth/OtpLoginForm";
|
import { OtpLoginForm } from "../components/auth/OtpLoginForm";
|
||||||
import {
|
import { useAuth } from "../context/AuthContext";
|
||||||
DEMO_LOGIN_EMAIL,
|
|
||||||
isRoleSwitchEntryEmail,
|
|
||||||
resolveLoginEmail,
|
|
||||||
useAuth,
|
|
||||||
} from "../context/AuthContext";
|
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
const { user, isOtpSent, isLoading, isDemoMode, authError, requestOtp, verifyOtp, signInWithRole } = useAuth();
|
const { user, isOtpSent, isLoading, authError, requestOtp, verifyOtp } = useAuth();
|
||||||
const [email, setEmail] = React.useState(() => (isDemoMode ? DEMO_LOGIN_EMAIL : ""));
|
const [email, setEmail] = React.useState("");
|
||||||
const [roleHint, setRoleHint] = React.useState("manager");
|
|
||||||
const [otp, setOtp] = React.useState("");
|
const [otp, setOtp] = React.useState("");
|
||||||
const [error, setError] = React.useState("");
|
const [error, setError] = React.useState("");
|
||||||
const isRoleSwitchMode = !isOtpSent && isRoleSwitchEntryEmail(email);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isDemoMode) {
|
|
||||||
setEmail(DEMO_LOGIN_EMAIL);
|
|
||||||
}
|
|
||||||
}, [isDemoMode]);
|
|
||||||
|
|
||||||
if (user) {
|
|
||||||
return <Navigate to="/dashboard" replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayError = error || authError;
|
const displayError = error || authError;
|
||||||
|
|
||||||
const handleRequestOtp = async () => {
|
const handleRequestOtp = async () => {
|
||||||
if (isRoleSwitchMode) {
|
const response = await requestOtp({ email });
|
||||||
const response = await signInWithRole(roleHint);
|
|
||||||
if (!response.success) {
|
|
||||||
setError(response.error?.message || "Не удалось выполнить вход");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setError("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await requestOtp(
|
|
||||||
isDemoMode
|
|
||||||
? { email: resolveLoginEmail(isDemoMode, email), roleHint }
|
|
||||||
: { email: resolveLoginEmail(isDemoMode, email) },
|
|
||||||
);
|
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
setError(response.error?.message || "Не удалось отправить код");
|
setError(response.error?.message || "Не удалось отправить код");
|
||||||
return;
|
return;
|
||||||
|
|
@ -52,7 +21,7 @@ export const LoginPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleVerifyOtp = async () => {
|
const handleVerifyOtp = async () => {
|
||||||
const response = await verifyOtp({ email: resolveLoginEmail(isDemoMode, email), otp });
|
const response = await verifyOtp({ email, otp });
|
||||||
if (!response.success) {
|
if (!response.success) {
|
||||||
setError(response.error?.message || "Не удалось подтвердить код");
|
setError(response.error?.message || "Не удалось подтвердить код");
|
||||||
return;
|
return;
|
||||||
|
|
@ -60,19 +29,19 @@ export const LoginPage = () => {
|
||||||
setError("");
|
setError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
return <Navigate to="/dashboard" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center px-3 py-6 sm:px-4 sm:py-10">
|
<div className="flex min-h-screen items-center justify-center px-3 py-6 sm:px-4 sm:py-10">
|
||||||
<OtpLoginForm
|
<OtpLoginForm
|
||||||
email={email}
|
email={email}
|
||||||
setEmail={setEmail}
|
setEmail={setEmail}
|
||||||
roleHint={roleHint}
|
|
||||||
setRoleHint={setRoleHint}
|
|
||||||
otp={otp}
|
otp={otp}
|
||||||
setOtp={setOtp}
|
setOtp={setOtp}
|
||||||
isOtpSent={isOtpSent}
|
isOtpSent={isOtpSent}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
isDemoMode={isDemoMode}
|
|
||||||
isRoleSwitchMode={isRoleSwitchMode}
|
|
||||||
onRequestOtp={handleRequestOtp}
|
onRequestOtp={handleRequestOtp}
|
||||||
onVerifyOtp={handleVerifyOtp}
|
onVerifyOtp={handleVerifyOtp}
|
||||||
error={displayError}
|
error={displayError}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ const cloneInvitation = (invitation) => JSON.parse(JSON.stringify(invitation));
|
||||||
const getLocalStorage = () => {
|
const getLocalStorage = () => {
|
||||||
try {
|
try {
|
||||||
return globalThis.localStorage || null;
|
return globalThis.localStorage || null;
|
||||||
} catch (error) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -36,7 +36,7 @@ const readStoredInvitation = (token) => {
|
||||||
try {
|
try {
|
||||||
const raw = storage.getItem(getLocalStorageKey(token));
|
const raw = storage.getItem(getLocalStorageKey(token));
|
||||||
return raw ? JSON.parse(raw) : null;
|
return raw ? JSON.parse(raw) : null;
|
||||||
} catch (error) {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -49,7 +49,7 @@ const writeStoredInvitation = (invitation) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
storage.setItem(getLocalStorageKey(invitation.token), JSON.stringify(invitation));
|
storage.setItem(getLocalStorageKey(invitation.token), JSON.stringify(invitation));
|
||||||
} catch (error) {
|
} catch {
|
||||||
// Ignore storage quota and privacy mode failures.
|
// Ignore storage quota and privacy mode failures.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ describe("orderService", () => {
|
||||||
customerName: "Тест Клиент",
|
customerName: "Тест Клиент",
|
||||||
customerPhone: "+7 978 777-00-00",
|
customerPhone: "+7 978 777-00-00",
|
||||||
customerAddress: "Симферополь",
|
customerAddress: "Симферополь",
|
||||||
messenger: "Телеграм",
|
messenger: "СМС",
|
||||||
managerId: "u-manager",
|
managerId: "u-manager",
|
||||||
deliveryDate: "2026-03-15",
|
deliveryDate: "2026-03-15",
|
||||||
items: "Тестовая позиция",
|
items: "Тестовая позиция",
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,12 @@ import { safeSupabaseCall } from "../safeSupabaseCall";
|
||||||
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
|
import { hasSupabaseConfig, supabase } from "../../supabaseClient";
|
||||||
|
|
||||||
const CHANNEL_CODES = {
|
const CHANNEL_CODES = {
|
||||||
"телеграм": "telegram",
|
|
||||||
"вконтакте": "vk",
|
|
||||||
"макс": "messenger_max",
|
|
||||||
max: "messenger_max",
|
|
||||||
"смс": "sms",
|
"смс": "sms",
|
||||||
"эл. почта": "email",
|
"эл. почта": "email",
|
||||||
"эл почта": "email",
|
"эл почта": "email",
|
||||||
"электронная почта": "email",
|
"электронная почта": "email",
|
||||||
telegram: "telegram",
|
|
||||||
vk: "vk",
|
|
||||||
sms: "sms",
|
sms: "sms",
|
||||||
email: "email",
|
email: "email",
|
||||||
messenger_max: "messenger_max",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const requireSupabase = () => {
|
const requireSupabase = () => {
|
||||||
|
|
@ -86,7 +79,7 @@ export const mapOrderRowToOrder = (row) => {
|
||||||
customer: {
|
customer: {
|
||||||
name: customer.name || "Без имени",
|
name: customer.name || "Без имени",
|
||||||
phone: customer.phone || "",
|
phone: customer.phone || "",
|
||||||
messenger: customer.messenger || "Телеграм",
|
messenger: customer.messenger || "СМС",
|
||||||
address: customer.address || "",
|
address: customer.address || "",
|
||||||
city: customer.city || "",
|
city: customer.city || "",
|
||||||
items: Array.isArray(customer.items) ? customer.items : [],
|
items: Array.isArray(customer.items) ? customer.items : [],
|
||||||
|
|
@ -174,7 +167,7 @@ export const buildOrderPayload = (order) => {
|
||||||
customer: {
|
customer: {
|
||||||
name: customer.name || "",
|
name: customer.name || "",
|
||||||
phone: customer.phone || "",
|
phone: customer.phone || "",
|
||||||
messenger: customer.messenger || "Телеграм",
|
messenger: customer.messenger || "СМС",
|
||||||
address: customer.address || "",
|
address: customer.address || "",
|
||||||
city: customer.city || "",
|
city: customer.city || "",
|
||||||
items: Array.isArray(order.items) ? order.items : Array.isArray(customer.items) ? customer.items : [],
|
items: Array.isArray(order.items) ? order.items : Array.isArray(customer.items) ? customer.items : [],
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ describe("orderRepository payloads", () => {
|
||||||
customer: {
|
customer: {
|
||||||
name: "Покупатель",
|
name: "Покупатель",
|
||||||
phone: "+7 978 000-00-00",
|
phone: "+7 978 000-00-00",
|
||||||
messenger: "Телеграм",
|
messenger: "СМС",
|
||||||
address: "Симферополь, ул. Ленина, 1",
|
address: "Симферополь, ул. Ленина, 1",
|
||||||
city: "Симферополь",
|
city: "Симферополь",
|
||||||
items: ["Позиция | 2 шт"],
|
items: ["Позиция | 2 шт"],
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
--color-base: #f4f7f5;
|
--color-base: #f4f7f5;
|
||||||
--color-surface: rgba(255, 255, 255, 0.78);
|
--color-surface: rgba(255, 255, 255, 0.78);
|
||||||
--color-surface-strong: #ffffff;
|
--color-surface-strong: #ffffff;
|
||||||
|
--color-dropdown-surface: #ffffff;
|
||||||
--color-text: #10211b;
|
--color-text: #10211b;
|
||||||
--color-text-muted: #5d6d66;
|
--color-text-muted: #5d6d66;
|
||||||
--color-accent: #12805c;
|
--color-accent: #12805c;
|
||||||
|
|
@ -19,6 +20,7 @@
|
||||||
--color-base: #09110f;
|
--color-base: #09110f;
|
||||||
--color-surface: rgba(16, 26, 23, 0.82);
|
--color-surface: rgba(16, 26, 23, 0.82);
|
||||||
--color-surface-strong: #0f1b18;
|
--color-surface-strong: #0f1b18;
|
||||||
|
--color-dropdown-surface: #0b1815;
|
||||||
--color-text: #ecf5f2;
|
--color-text: #ecf5f2;
|
||||||
--color-text-muted: #9eb0aa;
|
--color-text-muted: #9eb0aa;
|
||||||
--color-accent: #57d8a9;
|
--color-accent: #57d8a9;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue