Polish demo UI and delivery filters

This commit is contained in:
Codex 2026-04-27 20:37:21 +03:00
parent 72673e01fc
commit 5dcfa80940
36 changed files with 917 additions and 556 deletions

View File

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

View File

@ -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 зелёные.
- Есть отдельный сценарий показа заказчику без внутренних технических деталей.

View File

@ -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 не найден в системе. Обратитесь к администратору.»

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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", () => {

View File

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

View File

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

View File

@ -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("Что показывать заказчику");
});
});

View File

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

View File

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

View File

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

View File

@ -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,46 +68,51 @@ 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>
); );
})} })}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("Мария Волкова");
}); });

View File

@ -1 +1,13 @@
export { ORDER_STATUSES } from "./deliveryWorkflow"; export { ORDER_STATUSES } from "./deliveryWorkflow";
export const DELIVERY_REGISTRY_FILTER_STATUSES = [
"Готов к отгрузке",
"Ожидает согласования доставки",
"Доставка согласована",
"Назначен водитель",
"Загружен",
"В пути",
"Доставлен",
"Проблема доставки",
"Платное хранение",
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("Справочники");

View File

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

View File

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

View File

@ -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: "Тестовая позиция",

View File

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

View File

@ -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 шт"],

View File

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