Compare commits
2 commits
0ea2c06731
...
17374c3943
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17374c3943 | ||
|
|
b82adc414c |
112
docs/menu-links-progress.md
Normal file
112
docs/menu-links-progress.md
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
# Menu links: достижимость новых страниц Sprint 31
|
||||||
|
|
||||||
|
**Проблема (от координатора).** Маршруты `/purchases/invoices`,
|
||||||
|
`/purchases/invoices/:id`, `/purchases/supplies/scan` и
|
||||||
|
`/inventory/mobile` объявлены в `App.tsx`, но пользователь к ним
|
||||||
|
никак не доберётся: ни в sidebar (`AppLayout.tsx`), ни в
|
||||||
|
`CommandPalette`, ни в `QuickActionsPalette` ссылок нет. На страницах
|
||||||
|
`/purchases/supplies` и `/inventory/inventories` тоже нет кнопок,
|
||||||
|
которые бы вели на новые UX-ветки.
|
||||||
|
|
||||||
|
**Условия задачи.**
|
||||||
|
- Админ + Кладовщик видят новые пункты; Кассир — нет.
|
||||||
|
- Пункты видны в простом режиме (`isAdvancedMode === false`) тоже —
|
||||||
|
это базовые приёмка/инвентаризация, а не расширенные фичи.
|
||||||
|
- i18n: ru обязательно, en — потому что меню переводится.
|
||||||
|
- Playwright-проход «кликами с главной» на desktop (1280×800) +
|
||||||
|
mobile (375×667) под demo-keeper и demo-admin; плюс негатив-проход
|
||||||
|
для demo-cashier.
|
||||||
|
- Аудит сирот: диффим `<Route path>` из `App.tsx` против
|
||||||
|
фактических точек входа.
|
||||||
|
|
||||||
|
**Границы.** Не трогаем прод, `global.json`, `deploy/*/nginx`.
|
||||||
|
|
||||||
|
## Чек-лист
|
||||||
|
|
||||||
|
- [x] 1. Главное меню `AppLayout` — пункты «Накладные (по фото)» (`/purchases/invoices`) в разделе «Закупки» и «Инвентаризация с телефона» (`/inventory/mobile`) в разделе «Остатки» для ролей Admin / Storekeeper; видны в простом режиме; i18n ru + en.
|
||||||
|
- [x] 2. Dropdown-кнопки входа на `/purchases/supplies` и `/inventory/inventories`: «Новая приёмка ▾» → Вручную / По фото накладной / Сканированием; «Новая инвентаризация ▾» → Вручную / С телефона.
|
||||||
|
- [x] 3. CommandPalette (Cmd+K) + QuickActionsPalette (Cmd+J): добавить `invoices`, `supply-scan`, `inventory-mobile` в соответствующие списки.
|
||||||
|
- [x] 4. ПРОВЕРКА ГЛАЗАМИ — Playwright `stage-ui-menu-links.spec.ts`: 3 новых страницы кликами с главной под demo-keeper и demo-admin, desktop + mobile; скриншоты в `tests/e2e/reports/`. Негатив для demo-cashier.
|
||||||
|
- [x] 5. Аудит «сирот» — скрипт `tests/scripts/menu-orphans-audit.mjs`, отчёт в `docs/menu-orphans-report.md`.
|
||||||
|
- [x] 6. `~/deploy-stage.sh` + ретест п.4 на свежем stage.
|
||||||
|
|
||||||
|
## Протокол выполнения
|
||||||
|
|
||||||
|
### 1. Главное меню (AppLayout)
|
||||||
|
`src/food-market.web/src/components/AppLayout.tsx`:
|
||||||
|
- В секцию «Остатки» (`section_inventory`) добавлен пункт
|
||||||
|
`nav.inventoryMobile → /inventory/mobile` (icon `Smartphone`),
|
||||||
|
виден под условием `isAdmin || isStorekeeper` — т.е. без
|
||||||
|
`isCashier` — и в простом режиме, не завязан на `advanced`.
|
||||||
|
- В секцию «Закупки» (`section_purchases`) — пункт
|
||||||
|
`nav.invoices → /purchases/invoices` (icon `Camera`), те же
|
||||||
|
роли, без advanced-гарда.
|
||||||
|
- Параллельно вскрылся сирота `/admin/diagnostic` (см. п.5) —
|
||||||
|
добавлен в `section_admin` под Admin'ом (icon `Stethoscope`).
|
||||||
|
- i18n: обновили `locales/ru.json` + `locales/en.json`
|
||||||
|
(`nav.inventoryMobile`, `nav.invoices`, `nav.diagnostic`).
|
||||||
|
|
||||||
|
### 2. Split-кнопки на list-страницах
|
||||||
|
Новый компонент `src/food-market.web/src/components/SplitButtonMenu.tsx`
|
||||||
|
— dropdown-кнопка «Новая …» с клавиатурной навигацией (↑↓/Enter/Esc,
|
||||||
|
click-outside). Использована:
|
||||||
|
- `SuppliesPage.tsx`: «Новая приёмка ▾» → **Вручную** (`/purchases/supplies/new`),
|
||||||
|
**По фото накладной** (`/purchases/invoices`),
|
||||||
|
**Сканированием** (`/purchases/supplies/scan`).
|
||||||
|
- `InventoriesPage.tsx`: «Новая инвентаризация ▾» → **Вручную**
|
||||||
|
(`/inventory/inventories/new`), **С телефона** (`/inventory/mobile`).
|
||||||
|
Селекторы `data-testid=supplies-new-dropdown`, `inventories-new-dropdown`,
|
||||||
|
плюс каждый пункт меню — для e2e.
|
||||||
|
|
||||||
|
### 3. CommandPalette + QuickActions
|
||||||
|
`components/CommandPalette.tsx`: добавлены `page:invoices`,
|
||||||
|
`page:supply-scan`, `page:inventories` (ранее отсутствовал),
|
||||||
|
`page:inventory-mobile` в `STATIC_PAGES`. Иконки `Camera`, `ScanLine`,
|
||||||
|
`ClipboardCheck`, `Smartphone`.
|
||||||
|
`components/QuickActionsPalette.tsx`: действия `invoice-new`,
|
||||||
|
`supply-scan`, `inventory-mobile` в `ACTIONS`.
|
||||||
|
|
||||||
|
### 4. Playwright-проверка глазами
|
||||||
|
`tests/e2e/scenarios/stage-ui-menu-links.spec.ts` — 5 тестов:
|
||||||
|
- `demo-keeper / desktop` — ✓ 10.5s
|
||||||
|
- `demo-keeper / mobile` — ✓ 24.1s (drawer открывается hamburger'ом)
|
||||||
|
- `demo-admin / desktop` — ✓ 10.5s
|
||||||
|
- `demo-admin / mobile` — ✓ 10.3s
|
||||||
|
- `demo-cashier` negative — ✓ 3.9s (нет пунктов «Накладные (по фото)»,
|
||||||
|
«Инвентаризация с телефона», и раздела «Приёмки»)
|
||||||
|
|
||||||
|
Итого **5/5 passed** за 60 секунд против свежего stage
|
||||||
|
(`https://test.admin.food-market.kz`). Логин через sentinel-пароль
|
||||||
|
`__QUICK_LOGIN_PWLESS__` (тот же механизм, что SPA использует при
|
||||||
|
клике по карточке demo-аккаунта).
|
||||||
|
|
||||||
|
Скриншоты в `tests/e2e/reports/`:
|
||||||
|
`menu-links-{demo-admin|demo-keeper}-{desktop|mobile}-{invoices|supply-scan|inventory-mobile}.png`
|
||||||
|
(12 позитивных) + `menu-links-demo-cashier-desktop-sidebar.png`.
|
||||||
|
|
||||||
|
### 5. Orphan-audit
|
||||||
|
`tests/scripts/menu-orphans-audit.mjs` — парсит `App.tsx`,
|
||||||
|
диффит все `<Route path="…">` из tenant-ветки против всех строк,
|
||||||
|
встречающихся в sidebar / CommandPalette / QuickActionsPalette /
|
||||||
|
split-кнопках `SuppliesPage.tsx` + `InventoriesPage.tsx`.
|
||||||
|
|
||||||
|
Классификация: 57 tenant-маршрутов → 20 служебных
|
||||||
|
(`/login`, `/auth-bridge`, `/:id`-формы, `/new`-формы, супер-админ),
|
||||||
|
37 проверяемых. **После всех правок: 0 сирот.** Отчёт —
|
||||||
|
`docs/menu-orphans-report.md`.
|
||||||
|
|
||||||
|
Первый прогон обнаружил `/admin/diagnostic` — исправлено в п.1.
|
||||||
|
|
||||||
|
### 6. Deploy stage + retest
|
||||||
|
`~/deploy-stage.sh` → `[stage] OK — https://test.admin.food-market.kz`.
|
||||||
|
После деплоя тот же `stage-ui-menu-links.spec.ts` — **5/5 passed**.
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
|
||||||
|
Все 6 шагов выполнены. Новые экраны Sprint 31 полноценно встроены в
|
||||||
|
навигацию: sidebar + Cmd+K + Cmd+J + split-кнопки на list-страницах.
|
||||||
|
Автоматический orphan-audit в `tests/scripts/menu-orphans-audit.mjs`
|
||||||
|
можно гонять в CI как регрессионную защиту от повторения проблемы.
|
||||||
|
|
||||||
|
Прод, `global.json`, `deploy/nginx*` — не трогались.
|
||||||
|
|
||||||
75
docs/menu-orphans-report.md
Normal file
75
docs/menu-orphans-report.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Menu orphans audit
|
||||||
|
|
||||||
|
Generated: 2026-10-04T17:47:55.836Z
|
||||||
|
|
||||||
|
- tenant-маршрутов всего: **57**
|
||||||
|
- служебных (login / :id / /new / super-admin): **20**
|
||||||
|
- достижимо кликами: **37**
|
||||||
|
- сирот (не достижимо из UI): **0**
|
||||||
|
|
||||||
|
## Сироты
|
||||||
|
|
||||||
|
_Нет._ Все не-служебные tenant-маршруты достижимы через sidebar / Cmd+K / Cmd+J / кнопки на list-страницах.
|
||||||
|
|
||||||
|
## Достижимо
|
||||||
|
|
||||||
|
- `/`
|
||||||
|
- `/admin/diagnostic`
|
||||||
|
- `/admin/import/moysklad`
|
||||||
|
- `/audit-log`
|
||||||
|
- `/catalog/counterparties`
|
||||||
|
- `/catalog/price-types`
|
||||||
|
- `/catalog/product-groups`
|
||||||
|
- `/catalog/products`
|
||||||
|
- `/catalog/retail-points`
|
||||||
|
- `/catalog/stores`
|
||||||
|
- `/catalog/units`
|
||||||
|
- `/dashboard`
|
||||||
|
- `/help`
|
||||||
|
- `/inventory/enters`
|
||||||
|
- `/inventory/inventories`
|
||||||
|
- `/inventory/losses`
|
||||||
|
- `/inventory/mobile`
|
||||||
|
- `/inventory/movements`
|
||||||
|
- `/inventory/stock`
|
||||||
|
- `/inventory/transfers`
|
||||||
|
- `/loyalty/cards`
|
||||||
|
- `/loyalty/programs`
|
||||||
|
- `/promotions`
|
||||||
|
- `/purchases/invoices`
|
||||||
|
- `/purchases/supplier-returns`
|
||||||
|
- `/purchases/supplies`
|
||||||
|
- `/purchases/supplies/scan`
|
||||||
|
- `/reports/abc`
|
||||||
|
- `/reports/profit`
|
||||||
|
- `/reports/sales`
|
||||||
|
- `/reports/stock`
|
||||||
|
- `/sales/demands`
|
||||||
|
- `/sales/retail`
|
||||||
|
- `/settings/employee-roles`
|
||||||
|
- `/settings/employees`
|
||||||
|
- `/settings/organization`
|
||||||
|
- `/whats-new`
|
||||||
|
|
||||||
|
## Служебное (не требует точки входа)
|
||||||
|
|
||||||
|
- `/catalog/products/:id`
|
||||||
|
- `/catalog/products/new`
|
||||||
|
- `/inventory/enters/:id`
|
||||||
|
- `/inventory/enters/new`
|
||||||
|
- `/inventory/inventories/:id`
|
||||||
|
- `/inventory/inventories/new`
|
||||||
|
- `/inventory/losses/:id`
|
||||||
|
- `/inventory/losses/new`
|
||||||
|
- `/inventory/transfers/:id`
|
||||||
|
- `/inventory/transfers/new`
|
||||||
|
- `/onboarding-wizard`
|
||||||
|
- `/purchases/invoices/:id`
|
||||||
|
- `/purchases/supplier-returns/:id`
|
||||||
|
- `/purchases/supplier-returns/new`
|
||||||
|
- `/purchases/supplies/:id`
|
||||||
|
- `/purchases/supplies/new`
|
||||||
|
- `/sales/demands/:id`
|
||||||
|
- `/sales/demands/new`
|
||||||
|
- `/sales/retail/:id`
|
||||||
|
- `/sales/retail/new`
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
LayoutDashboard, Package, FolderTree, Ruler, Tag,
|
LayoutDashboard, Package, FolderTree, Ruler, Tag,
|
||||||
Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck,
|
Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck,
|
||||||
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send, FileText,
|
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send, FileText,
|
||||||
|
Camera, Smartphone, Stethoscope,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
|
|
@ -112,6 +113,9 @@ function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
|
||||||
stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'nav.transfers' })
|
stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'nav.transfers' })
|
||||||
}
|
}
|
||||||
stock.push({ to: '/inventory/inventories', icon: ClipboardCheck, label: 'nav.inventories' })
|
stock.push({ to: '/inventory/inventories', icon: ClipboardCheck, label: 'nav.inventories' })
|
||||||
|
// Sprint 31: мобильная инвентаризация. Простой режим тоже видит —
|
||||||
|
// это базовый сценарий «ходи по полкам с телефоном».
|
||||||
|
stock.push({ to: '/inventory/mobile', icon: Smartphone, label: 'nav.inventoryMobile' })
|
||||||
}
|
}
|
||||||
sections.push({ group: 'nav.section_inventory', items: stock })
|
sections.push({ group: 'nav.section_inventory', items: stock })
|
||||||
}
|
}
|
||||||
|
|
@ -120,6 +124,9 @@ function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
|
||||||
if (isAdmin || isStorekeeper) {
|
if (isAdmin || isStorekeeper) {
|
||||||
sections.push({ group: 'nav.section_purchases', items: [
|
sections.push({ group: 'nav.section_purchases', items: [
|
||||||
{ to: '/purchases/supplies', icon: TruckIcon, label: 'nav.supplies' },
|
{ to: '/purchases/supplies', icon: TruckIcon, label: 'nav.supplies' },
|
||||||
|
// Sprint 31: приёмка по фото накладной. Виден и в простом режиме —
|
||||||
|
// это замена ручной набивки накладной, не расширенная фича.
|
||||||
|
{ to: '/purchases/invoices', icon: Camera, label: 'nav.invoices' },
|
||||||
{ to: '/purchases/supplier-returns', icon: Undo2, label: 'nav.supplierReturns' },
|
{ to: '/purchases/supplier-returns', icon: Undo2, label: 'nav.supplierReturns' },
|
||||||
]})
|
]})
|
||||||
}
|
}
|
||||||
|
|
@ -152,6 +159,10 @@ function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
sections.push({ group: 'nav.section_admin', items: [
|
sections.push({ group: 'nav.section_admin', items: [
|
||||||
{ to: '/admin/import/moysklad', icon: Download, label: 'nav.moysklad' },
|
{ to: '/admin/import/moysklad', icon: Download, label: 'nav.moysklad' },
|
||||||
|
// Диагностика магазина — проверки целостности остатков, воркеров,
|
||||||
|
// БД. Был объявлен в App.tsx, но не было точки входа — попадал
|
||||||
|
// в orphan-отчёт.
|
||||||
|
{ to: '/admin/diagnostic', icon: Stethoscope, label: 'nav.diagnostic' },
|
||||||
]})
|
]})
|
||||||
sections.push({ group: 'nav.section_settings', items: [
|
sections.push({ group: 'nav.section_settings', items: [
|
||||||
{ to: '/settings/organization', icon: Settings, label: 'nav.settingsGeneral' },
|
{ to: '/settings/organization', icon: Settings, label: 'nav.settingsGeneral' },
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
Search, Package, Users, FileText, ShoppingCart, BarChart3, Settings,
|
Search, Package, Users, FileText, ShoppingCart, BarChart3, Settings,
|
||||||
Boxes, TruckIcon, Send, Tag, Shield, History, Warehouse, ArrowRight,
|
Boxes, TruckIcon, Send, Tag, Shield, History, Warehouse, ArrowRight,
|
||||||
|
Camera, ScanLine, Smartphone, ClipboardCheck,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
|
|
||||||
|
|
@ -45,6 +46,11 @@ const STATIC_PAGES: Array<Omit<PaletteItem, 'action'> & { path: string }> = [
|
||||||
{ id: 'page:stock', label: 'Остатки', icon: Boxes, group: 'pages', path: '/inventory/stock' },
|
{ id: 'page:stock', label: 'Остатки', icon: Boxes, group: 'pages', path: '/inventory/stock' },
|
||||||
{ id: 'page:movements', label: 'Движения товаров', icon: History, group: 'pages', path: '/inventory/movements' },
|
{ id: 'page:movements', label: 'Движения товаров', icon: History, group: 'pages', path: '/inventory/movements' },
|
||||||
{ id: 'page:supplies', label: 'Закупки · Приёмки', icon: TruckIcon, group: 'pages', path: '/purchases/supplies' },
|
{ id: 'page:supplies', label: 'Закупки · Приёмки', icon: TruckIcon, group: 'pages', path: '/purchases/supplies' },
|
||||||
|
// Sprint 31 — новые UX-ветки приёмки/инвентаризации, достижимые через Cmd+K.
|
||||||
|
{ id: 'page:invoices', label: 'Закупки · Накладные (по фото)', icon: Camera, group: 'pages', path: '/purchases/invoices' },
|
||||||
|
{ id: 'page:supply-scan', label: 'Закупки · Приёмка сканированием', icon: ScanLine, group: 'pages', path: '/purchases/supplies/scan' },
|
||||||
|
{ id: 'page:inventories', label: 'Инвентаризации', icon: ClipboardCheck, group: 'pages', path: '/inventory/inventories' },
|
||||||
|
{ id: 'page:inventory-mobile', label: 'Инвентаризация с телефона', icon: Smartphone, group: 'pages', path: '/inventory/mobile' },
|
||||||
{ id: 'page:enters', label: 'Оприходования', icon: Boxes, group: 'pages', path: '/inventory/enters' },
|
{ id: 'page:enters', label: 'Оприходования', icon: Boxes, group: 'pages', path: '/inventory/enters' },
|
||||||
{ id: 'page:retail-sales', label: 'Продажи · Розничные чеки', icon: ShoppingCart, group: 'pages', path: '/sales/retail' },
|
{ id: 'page:retail-sales', label: 'Продажи · Розничные чеки', icon: ShoppingCart, group: 'pages', path: '/sales/retail' },
|
||||||
{ id: 'page:demands', label: 'Продажи · Оптовые отгрузки', icon: Send, group: 'pages', path: '/sales/demands' },
|
{ id: 'page:demands', label: 'Продажи · Оптовые отгрузки', icon: Send, group: 'pages', path: '/sales/demands' },
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { useNavigate } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
Plus, ShoppingCart, BarChart3, TruckIcon, Users, Boxes,
|
Plus, ShoppingCart, BarChart3, TruckIcon, Users, Boxes,
|
||||||
PackagePlus, PackageMinus, ArrowRightLeft, Tag, Archive, ClipboardCheck,
|
PackagePlus, PackageMinus, ArrowRightLeft, Tag, Archive, ClipboardCheck,
|
||||||
|
Camera, ScanLine, Smartphone,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
interface QuickAction {
|
interface QuickAction {
|
||||||
|
|
@ -38,11 +39,16 @@ const ACTIONS: QuickAction[] = [
|
||||||
{ id: 'product-new', label: 'Создать товар', icon: Plus, run: (n) => n('/catalog/products/new') },
|
{ id: 'product-new', label: 'Создать товар', icon: Plus, run: (n) => n('/catalog/products/new') },
|
||||||
{ id: 'sale-new', label: 'Провести продажу', icon: ShoppingCart, run: (n) => n('/sales/retail/new') },
|
{ id: 'sale-new', label: 'Провести продажу', icon: ShoppingCart, run: (n) => n('/sales/retail/new') },
|
||||||
{ id: 'supply-new', label: 'Создать приёмку', icon: TruckIcon, run: (n) => n('/purchases/supplies/new') },
|
{ id: 'supply-new', label: 'Создать приёмку', icon: TruckIcon, run: (n) => n('/purchases/supplies/new') },
|
||||||
|
// Sprint 31 — три входа в приёмку/инвентаризацию через Cmd+J, иначе
|
||||||
|
// новые экраны остаются «погребёнными» внутри split-кнопок.
|
||||||
|
{ id: 'invoice-new', label: 'Приёмка по фото накладной', icon: Camera, run: (n) => n('/purchases/invoices') },
|
||||||
|
{ id: 'supply-scan', label: 'Приёмка сканированием', icon: ScanLine, run: (n) => n('/purchases/supplies/scan') },
|
||||||
{ id: 'counterparty-new', label: 'Добавить контрагента', icon: Users, run: (n) => n('/catalog/counterparties/new') },
|
{ id: 'counterparty-new', label: 'Добавить контрагента', icon: Users, run: (n) => n('/catalog/counterparties/new') },
|
||||||
{ id: 'enter-new', label: 'Оприходование', icon: PackagePlus, run: (n) => n('/inventory/enters/new') },
|
{ id: 'enter-new', label: 'Оприходование', icon: PackagePlus, run: (n) => n('/inventory/enters/new') },
|
||||||
{ id: 'loss-new', label: 'Списание', icon: PackageMinus, run: (n) => n('/inventory/losses/new') },
|
{ id: 'loss-new', label: 'Списание', icon: PackageMinus, run: (n) => n('/inventory/losses/new') },
|
||||||
{ id: 'transfer-new', label: 'Перемещение', icon: ArrowRightLeft, run: (n) => n('/inventory/transfers/new') },
|
{ id: 'transfer-new', label: 'Перемещение', icon: ArrowRightLeft, run: (n) => n('/inventory/transfers/new') },
|
||||||
{ id: 'inventory-new', label: 'Инвентаризация', icon: ClipboardCheck, run: (n) => n('/inventory/inventories/new') },
|
{ id: 'inventory-new', label: 'Инвентаризация', icon: ClipboardCheck, run: (n) => n('/inventory/inventories/new') },
|
||||||
|
{ id: 'inventory-mobile', label: 'Инвентаризация с телефона', icon: Smartphone, run: (n) => n('/inventory/mobile') },
|
||||||
{ id: 'report-sales', label: 'Отчёт «Продажи»', icon: BarChart3, run: (n) => n('/reports/sales') },
|
{ id: 'report-sales', label: 'Отчёт «Продажи»', icon: BarChart3, run: (n) => n('/reports/sales') },
|
||||||
{ id: 'report-stock', label: 'Отчёт «Остатки»', icon: Boxes, run: (n) => n('/reports/stock') },
|
{ id: 'report-stock', label: 'Отчёт «Остатки»', icon: Boxes, run: (n) => n('/reports/stock') },
|
||||||
{ id: 'report-profit', label: 'Отчёт «Прибыль»', icon: BarChart3, run: (n) => n('/reports/profit') },
|
{ id: 'report-profit', label: 'Отчёт «Прибыль»', icon: BarChart3, run: (n) => n('/reports/profit') },
|
||||||
|
|
|
||||||
135
src/food-market.web/src/components/SplitButtonMenu.tsx
Normal file
135
src/food-market.web/src/components/SplitButtonMenu.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import { useEffect, useId, useRef, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { ChevronDown, Plus } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dropdown-кнопка «Новая …» с несколькими вариантами входа. Используется
|
||||||
|
* на list-страницах где одна и та же бизнес-операция (приёмка, инвентаризация)
|
||||||
|
* имеет несколько UX-веток: ручной ввод, по фото, сканированием, с телефона.
|
||||||
|
*
|
||||||
|
* Поведение:
|
||||||
|
* - клик по кнопке → раскрывается меню;
|
||||||
|
* - клик по пункту → navigate(to), меню закрывается;
|
||||||
|
* - Esc / click-outside → закрывает;
|
||||||
|
* - стрелки ↑↓ + Enter — клавиатурная навигация внутри меню.
|
||||||
|
*
|
||||||
|
* Из соображений простоты не используем Radix / headlessui — все
|
||||||
|
* list-страницы и так рендерят максимум одну такую кнопку.
|
||||||
|
*/
|
||||||
|
export interface SplitButtonMenuItem {
|
||||||
|
/** Короткий label. */
|
||||||
|
label: string
|
||||||
|
/** Подсказка под label'ом (например, «фото → распознавание»). */
|
||||||
|
hint?: string
|
||||||
|
/** Куда navigate при выборе. */
|
||||||
|
to: string
|
||||||
|
/** Иконка слева (lucide). */
|
||||||
|
icon?: React.ComponentType<{ className?: string }>
|
||||||
|
/** Тестовый селектор для Playwright. */
|
||||||
|
testId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SplitButtonMenuProps {
|
||||||
|
/** Label главной кнопки, например «Новая приёмка». */
|
||||||
|
label: string
|
||||||
|
items: SplitButtonMenuItem[]
|
||||||
|
/** Селектор для корневой кнопки (чтобы e2e открывали именно её). */
|
||||||
|
testId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SplitButtonMenu({ label, items, testId }: SplitButtonMenuProps) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [active, setActive] = useState(0)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const menuId = useId()
|
||||||
|
|
||||||
|
// Click-outside + Esc.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (!containerRef.current?.contains(e.target as Node)) setOpen(false)
|
||||||
|
}
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') { e.preventDefault(); setOpen(false) }
|
||||||
|
else if (e.key === 'ArrowDown') { e.preventDefault(); setActive(a => Math.min(items.length - 1, a + 1)) }
|
||||||
|
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(a => Math.max(0, a - 1)) }
|
||||||
|
else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
const it = items[active]
|
||||||
|
if (it) { setOpen(false); navigate(it.to) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', onClick)
|
||||||
|
document.addEventListener('keydown', onKey)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onClick)
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
}
|
||||||
|
}, [open, items, active, navigate])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={containerRef} className="relative inline-block">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid={testId}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={menuId}
|
||||||
|
onClick={() => { setOpen(o => !o); setActive(0) }}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-md font-medium transition-colors',
|
||||||
|
'bg-[var(--color-brand)] hover:bg-[var(--color-brand-hover)] text-white',
|
||||||
|
'px-3.5 py-1.5 text-sm',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>{label}</span>
|
||||||
|
<ChevronDown className={cn('w-3.5 h-3.5 transition-transform', open && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div
|
||||||
|
id={menuId}
|
||||||
|
role="menu"
|
||||||
|
className={cn(
|
||||||
|
'absolute right-0 mt-1 z-30 min-w-[18rem] rounded-md border bg-white dark:bg-slate-900',
|
||||||
|
'border-slate-200 dark:border-slate-700 shadow-lg overflow-hidden',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{items.map((it, i) => {
|
||||||
|
const Icon = it.icon
|
||||||
|
const isActive = i === active
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={it.to + ':' + it.label}
|
||||||
|
role="menuitem"
|
||||||
|
type="button"
|
||||||
|
data-testid={it.testId}
|
||||||
|
onMouseEnter={() => setActive(i)}
|
||||||
|
onClick={() => { setOpen(false); navigate(it.to) }}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-3 py-2 flex items-start gap-3 text-sm',
|
||||||
|
isActive
|
||||||
|
? 'bg-emerald-50 dark:bg-emerald-900/30 text-slate-900 dark:text-slate-100'
|
||||||
|
: 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800/50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{Icon && <Icon className="w-4 h-4 mt-0.5 flex-shrink-0 text-slate-500 dark:text-slate-400" />}
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="block font-medium">{it.label}</span>
|
||||||
|
{it.hint && (
|
||||||
|
<span className="block text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
{it.hint}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -65,7 +65,9 @@
|
||||||
"losses": "Write-offs",
|
"losses": "Write-offs",
|
||||||
"transfers": "Transfers",
|
"transfers": "Transfers",
|
||||||
"inventories": "Stocktake",
|
"inventories": "Stocktake",
|
||||||
|
"inventoryMobile": "Mobile stocktake",
|
||||||
"supplies": "Supplies",
|
"supplies": "Supplies",
|
||||||
|
"invoices": "Invoices (photo)",
|
||||||
"supplierReturns": "Supplier returns",
|
"supplierReturns": "Supplier returns",
|
||||||
"retailSales": "Retail sales",
|
"retailSales": "Retail sales",
|
||||||
"demands": "Wholesale",
|
"demands": "Wholesale",
|
||||||
|
|
@ -78,6 +80,7 @@
|
||||||
"reportAbc": "ABC analysis",
|
"reportAbc": "ABC analysis",
|
||||||
"auditLog": "Audit log",
|
"auditLog": "Audit log",
|
||||||
"moysklad": "MoySklad",
|
"moysklad": "MoySklad",
|
||||||
|
"diagnostic": "Diagnostics",
|
||||||
"settingsGeneral": "General",
|
"settingsGeneral": "General",
|
||||||
"stores": "Stores",
|
"stores": "Stores",
|
||||||
"retailPoints": "Cashiers",
|
"retailPoints": "Cashiers",
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,9 @@
|
||||||
"losses": "Списания",
|
"losses": "Списания",
|
||||||
"transfers": "Перемещения",
|
"transfers": "Перемещения",
|
||||||
"inventories": "Инвентаризации",
|
"inventories": "Инвентаризации",
|
||||||
|
"inventoryMobile": "Инвентаризация с телефона",
|
||||||
"supplies": "Приёмки",
|
"supplies": "Приёмки",
|
||||||
|
"invoices": "Накладные (по фото)",
|
||||||
"supplierReturns": "Возвраты поставщикам",
|
"supplierReturns": "Возвраты поставщикам",
|
||||||
"retailSales": "Розничные чеки",
|
"retailSales": "Розничные чеки",
|
||||||
"demands": "Оптовые отгрузки",
|
"demands": "Оптовые отгрузки",
|
||||||
|
|
@ -78,6 +80,7 @@
|
||||||
"reportAbc": "ABC-анализ",
|
"reportAbc": "ABC-анализ",
|
||||||
"auditLog": "Журнал изменений",
|
"auditLog": "Журнал изменений",
|
||||||
"moysklad": "МойСклад",
|
"moysklad": "МойСклад",
|
||||||
|
"diagnostic": "Диагностика",
|
||||||
"settingsGeneral": "Общие",
|
"settingsGeneral": "Общие",
|
||||||
"stores": "Склады",
|
"stores": "Склады",
|
||||||
"retailPoints": "Кассы",
|
"retailPoints": "Кассы",
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Plus, ClipboardList } from 'lucide-react'
|
import { ClipboardList, Pencil, Smartphone } from 'lucide-react'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { SplitButtonMenu } from '@/components/SplitButtonMenu'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
import { useShortcuts } from '@/lib/useShortcuts'
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
|
|
@ -37,9 +37,18 @@ export function InventoriesPage() {
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
|
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
|
||||||
<Link to="/inventory/inventories/new">
|
{/* Sprint 31: мобильная инвентаризация — альтернативный вход
|
||||||
<Button><Plus className="w-4 h-4" /> Новая инвентаризация</Button>
|
для сверки на складе с телефона. Ручной — старое поведение. */}
|
||||||
</Link>
|
<SplitButtonMenu
|
||||||
|
label="Новая инвентаризация"
|
||||||
|
testId="inventories-new-dropdown"
|
||||||
|
items={[
|
||||||
|
{ label: 'Вручную', hint: 'Создать черновик и сверять за столом',
|
||||||
|
to: '/inventory/inventories/new', icon: Pencil, testId: 'inventories-new-manual' },
|
||||||
|
{ label: 'С телефона', hint: 'Офлайн-каталог, сканируем на складе, расхождения в конце',
|
||||||
|
to: '/inventory/mobile', icon: Smartphone, testId: 'inventories-new-mobile' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
footer={data && data.total > 0 && (
|
footer={data && data.total > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { useRef } from 'react'
|
import { useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Plus, PackagePlus } from 'lucide-react'
|
import { PackagePlus, Pencil, Camera, ScanLine } from 'lucide-react'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { ExportButton } from '@/components/ExportButton'
|
import { ExportButton } from '@/components/ExportButton'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { SplitButtonMenu } from '@/components/SplitButtonMenu'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
import { useShortcuts } from '@/lib/useShortcuts'
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
|
|
@ -39,9 +39,21 @@ export function SuppliesPage() {
|
||||||
<>
|
<>
|
||||||
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
|
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
|
||||||
<ExportButton url="/api/purchases/supplies/export" defaultName="supplies" />
|
<ExportButton url="/api/purchases/supplies/export" defaultName="supplies" />
|
||||||
<Link to="/purchases/supplies/new">
|
{/* Sprint 31: три входа в создание приёмки — ручной, по фото накладной,
|
||||||
<Button><Plus className="w-4 h-4" /> Новая приёмка</Button>
|
сканированием штрихкодов. Ручной — старое поведение; фото и скан —
|
||||||
</Link>
|
новые UX-ветки, в sidebar не продублированы (это именно действия). */}
|
||||||
|
<SplitButtonMenu
|
||||||
|
label="Новая приёмка"
|
||||||
|
testId="supplies-new-dropdown"
|
||||||
|
items={[
|
||||||
|
{ label: 'Вручную', hint: 'Создать пустую приёмку и набить позиции',
|
||||||
|
to: '/purchases/supplies/new', icon: Pencil, testId: 'supplies-new-manual' },
|
||||||
|
{ label: 'По фото накладной', hint: 'Загрузи фото — распознаем позиции и соберём черновик',
|
||||||
|
to: '/purchases/invoices', icon: Camera, testId: 'supplies-new-invoice' },
|
||||||
|
{ label: 'Сканированием', hint: 'Камерой телефона: штрихкод + количество',
|
||||||
|
to: '/purchases/supplies/scan', icon: ScanLine, testId: 'supplies-new-scan' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
footer={data && data.total > 0 && (
|
footer={data && data.total > 0 && (
|
||||||
|
|
|
||||||
218
tests/e2e/scenarios/stage-ui-menu-links.spec.ts
Normal file
218
tests/e2e/scenarios/stage-ui-menu-links.spec.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
/**
|
||||||
|
* Sprint 31 — menu-links coverage.
|
||||||
|
*
|
||||||
|
* Проверяет, что все три новых экрана (накладные по фото, приёмка
|
||||||
|
* сканированием, инвентаризация с телефона) достижимы кликами с
|
||||||
|
* главной страницы, а не только по прямому URL:
|
||||||
|
*
|
||||||
|
* demo-keeper + demo-admin — пункты видны;
|
||||||
|
* demo-cashier — пунктов НЕ видно (роль-гард).
|
||||||
|
*
|
||||||
|
* Виды viewport'ов: desktop 1280×800 и mobile 375×667. На mobile
|
||||||
|
* сначала открываем hamburger-drawer; на desktop sidebar виден сразу.
|
||||||
|
*
|
||||||
|
* Логин — через sentinel-пароль quick-login (`__QUICK_LOGIN_PWLESS__`),
|
||||||
|
* тот же механизм, что SPA использует при клике по карточке. Токен
|
||||||
|
* кладём в localStorage, страницу перегружаем — Suspense/lazy чанки
|
||||||
|
* подтягиваются из сети как обычно.
|
||||||
|
*
|
||||||
|
* Скриншоты складываем в tests/e2e/reports/menu-links-*.png — туда же,
|
||||||
|
* куда предыдущие визуальные артефакты.
|
||||||
|
*/
|
||||||
|
import { test, expect, type Page, type APIRequestContext } from '@playwright/test'
|
||||||
|
import { request as apiRequest } from '@playwright/test'
|
||||||
|
import { loginToken, watchPage, expectNoErrors } from '../lib/ui.js'
|
||||||
|
|
||||||
|
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
|
||||||
|
const SENTINEL = '__QUICK_LOGIN_PWLESS__'
|
||||||
|
|
||||||
|
const DEMO_ADMIN = 'demo-admin@food-market.local'
|
||||||
|
const DEMO_KEEPER = 'demo-keeper@food-market.local'
|
||||||
|
const DEMO_CASHIER = 'demo-cashier@food-market.local'
|
||||||
|
|
||||||
|
type Viewport = { name: 'desktop' | 'mobile'; width: number; height: number }
|
||||||
|
const VIEWPORTS: Viewport[] = [
|
||||||
|
{ name: 'desktop', width: 1280, height: 800 },
|
||||||
|
{ name: 'mobile', width: 375, height: 667 },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Три новых экрана. `linkText` — то, что видно пользователю в sidebar или
|
||||||
|
// в split-меню. `fromSidebar=false` — пункт достигается через split-кнопку
|
||||||
|
// на list-странице, а не напрямую из sidebar; тогда нужен промежуточный
|
||||||
|
// клик по разделу.
|
||||||
|
const NEW_PAGES: Array<{
|
||||||
|
slug: string
|
||||||
|
linkText: RegExp
|
||||||
|
expectUrl: RegExp
|
||||||
|
headingHint: RegExp
|
||||||
|
via: 'sidebar' | { sidebarLink: RegExp; splitButton: string; menuItem: RegExp }
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
slug: 'invoices',
|
||||||
|
linkText: /Накладные \(по фото\)/i,
|
||||||
|
expectUrl: /\/purchases\/invoices(?!\/)/,
|
||||||
|
headingHint: /накладн/i,
|
||||||
|
via: 'sidebar',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'supply-scan',
|
||||||
|
linkText: /Сканированием/i,
|
||||||
|
expectUrl: /\/purchases\/supplies\/scan/,
|
||||||
|
headingHint: /скан|приём|приемк/i,
|
||||||
|
via: {
|
||||||
|
sidebarLink: /^Приёмки$|^Приемки$/,
|
||||||
|
splitButton: '[data-testid="supplies-new-dropdown"]',
|
||||||
|
menuItem: /Сканированием/i,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'inventory-mobile',
|
||||||
|
linkText: /Инвентаризация с телефона/i,
|
||||||
|
expectUrl: /\/inventory\/mobile/,
|
||||||
|
headingHint: /инвентар|склад|телефон/i,
|
||||||
|
via: 'sidebar',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function obtainToken(email: string): Promise<string> {
|
||||||
|
const ctx = await apiRequest.newContext({ baseURL: BASE, ignoreHTTPSErrors: true, timeout: 60_000 })
|
||||||
|
const tok = await loginToken(ctx as unknown as APIRequestContext, email, SENTINEL)
|
||||||
|
await ctx.dispose()
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
|
||||||
|
async function attachAndGoHome(page: Page, token: string) {
|
||||||
|
await page.goto(`${BASE}/login`, { waitUntil: 'domcontentloaded' })
|
||||||
|
await page.evaluate((tok) => localStorage.setItem('fm.access_token', tok), token)
|
||||||
|
// Главная (/) — OnboardingPage; у неё в sidebar — все остальные пункты.
|
||||||
|
await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' })
|
||||||
|
// Ждём, пока React-роут отработает и sidebar появится.
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 20_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSidebar(page: Page, vp: Viewport) {
|
||||||
|
if (vp.name === 'desktop') return
|
||||||
|
// На mobile sidebar в drawer'е, открыть по hamburger-кнопке.
|
||||||
|
const hamburger = page.getByRole('button', { name: /Открыть меню|Open menu/i })
|
||||||
|
await hamburger.click()
|
||||||
|
// Overlay drawer рендерится как dialog с role=dialog aria-modal.
|
||||||
|
await expect(page.locator('aside').last()).toBeVisible({ timeout: 5_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sidebarLink(page: Page, text: RegExp) {
|
||||||
|
// На desktop sidebar — <aside class="hidden md:flex">; на mobile — <aside>
|
||||||
|
// внутри drawer-overlay. getByRole('link', {name}) матчит оба.
|
||||||
|
return page.locator('aside').locator('visible=true').getByRole('link', { name: text }).first()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function screenshot(page: Page, slug: string, who: string, vp: Viewport) {
|
||||||
|
const path = `reports/menu-links-${who}-${vp.name}-${slug}.png`
|
||||||
|
await page.screenshot({ path, fullPage: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
test.describe('menu-links — все три новые страницы достижимы кликами', () => {
|
||||||
|
// НЕ serial — тесты не делят state. serial-mode приводил к тому, что
|
||||||
|
// одна фоновая 403 на керпере скрывала остальные три теста.
|
||||||
|
|
||||||
|
for (const user of [DEMO_KEEPER, DEMO_ADMIN]) {
|
||||||
|
const who = user.split('@')[0]
|
||||||
|
for (const vp of VIEWPORTS) {
|
||||||
|
test(`${who} / ${vp.name} — доходим кликами до всех 3 новых экранов`, async ({ browser }) => {
|
||||||
|
test.setTimeout(180_000)
|
||||||
|
const token = await obtainToken(user)
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: vp.width, height: vp.height },
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
locale: 'ru-RU',
|
||||||
|
})
|
||||||
|
const page = await context.newPage()
|
||||||
|
const errs = watchPage(page, {
|
||||||
|
// Фоновые пробы, которые sidebar делает всегда — на кладовщике
|
||||||
|
// некоторые из них возвращают 403 (роль-гард), и это ожидаемо.
|
||||||
|
// Нам тут не хочется ловить предсуществующие ошибки — только
|
||||||
|
// регрессии от навигации.
|
||||||
|
expected4xxContains: [
|
||||||
|
'/api/notifications',
|
||||||
|
'/api/organization/employees',
|
||||||
|
'/api/super-admin/',
|
||||||
|
'/api/dashboard/widgets',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await attachAndGoHome(page, token)
|
||||||
|
|
||||||
|
for (const target of NEW_PAGES) {
|
||||||
|
// Для каждого таргета возвращаемся на главную, чтобы путь
|
||||||
|
// «с главной страницы» был честным.
|
||||||
|
await page.goto(`${BASE}/`, { waitUntil: 'domcontentloaded' })
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 20_000 })
|
||||||
|
await openSidebar(page, vp)
|
||||||
|
|
||||||
|
if (target.via === 'sidebar') {
|
||||||
|
const link = await sidebarLink(page, target.linkText)
|
||||||
|
await expect(link, `пункт «${target.linkText}» в sidebar`).toBeVisible({ timeout: 5_000 })
|
||||||
|
await link.click()
|
||||||
|
} else {
|
||||||
|
const list = await sidebarLink(page, target.via.sidebarLink)
|
||||||
|
await expect(list, 'раздел-список для split-кнопки').toBeVisible({ timeout: 5_000 })
|
||||||
|
await list.click()
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 15_000 })
|
||||||
|
const btn = page.locator(target.via.splitButton).first()
|
||||||
|
await expect(btn, 'split-кнопка').toBeVisible({ timeout: 5_000 })
|
||||||
|
await btn.click()
|
||||||
|
const item = page.getByRole('menuitem', { name: target.via.menuItem }).first()
|
||||||
|
await expect(item, 'пункт меню split-кнопки').toBeVisible({ timeout: 5_000 })
|
||||||
|
await item.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.waitForURL(target.expectUrl, { timeout: 15_000 })
|
||||||
|
// Страница рендерится — body непустой + подсказка на heading.
|
||||||
|
await page.waitForLoadState('networkidle', { timeout: 20_000 })
|
||||||
|
const bodyLen = (await page.locator('body').innerText()).length
|
||||||
|
expect(bodyLen, `body на ${target.slug}`).toBeGreaterThan(100)
|
||||||
|
|
||||||
|
await screenshot(page, target.slug, who, vp)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectNoErrors(errs, `${who}/${vp.name} click-walk`)
|
||||||
|
} finally {
|
||||||
|
await context.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('demo-cashier НЕ видит пункты «Накладные (по фото)», «Инвентаризация с телефона», split-кнопка приёмки отсутствует', async ({ browser }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
const token = await obtainToken(DEMO_CASHIER)
|
||||||
|
// На desktop достаточно: кассир и так видит очень ограниченное меню.
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1280, height: 800 },
|
||||||
|
ignoreHTTPSErrors: true,
|
||||||
|
locale: 'ru-RU',
|
||||||
|
})
|
||||||
|
const page = await context.newPage()
|
||||||
|
try {
|
||||||
|
await attachAndGoHome(page, token)
|
||||||
|
|
||||||
|
const asideLinks = page.locator('aside').locator('visible=true').getByRole('link')
|
||||||
|
|
||||||
|
await expect(asideLinks.filter({ hasText: /Накладные \(по фото\)/i }), 'нет пункта «Накладные (по фото)»').toHaveCount(0)
|
||||||
|
await expect(asideLinks.filter({ hasText: /Инвентаризация с телефона/i }), 'нет пункта «Инвентаризация с телефона»').toHaveCount(0)
|
||||||
|
|
||||||
|
// На /purchases/supplies у кассира доступа нет (роль-гард серверный);
|
||||||
|
// но если бы был — split-кнопка видна только там. Поэтому раздел
|
||||||
|
// «Закупки» для кассира в sidebar вообще отсутствует.
|
||||||
|
await expect(asideLinks.filter({ hasText: /^Приёмки$|^Приемки$/ }), 'нет раздела «Приёмки» у кассира').toHaveCount(0)
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'reports/menu-links-demo-cashier-desktop-sidebar.png', fullPage: true })
|
||||||
|
} finally {
|
||||||
|
await context.close()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
163
tests/scripts/menu-orphans-audit.mjs
Executable file
163
tests/scripts/menu-orphans-audit.mjs
Executable file
|
|
@ -0,0 +1,163 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Аудит «сиротских» tenant-маршрутов: читает App.tsx, достаёт все
|
||||||
|
* <Route path="/..."> внутри tenant-блока, сравнивает со всеми строками,
|
||||||
|
* до которых реально можно добраться кликами из UI — через sidebar
|
||||||
|
* (AppLayout.tsx), командную палитру (CommandPalette.tsx), палитру
|
||||||
|
* быстрых действий (QuickActionsPalette.tsx), SplitButtonMenu-экшены
|
||||||
|
* на list-страницах (SuppliesPage.tsx, InventoriesPage.tsx).
|
||||||
|
*
|
||||||
|
* Сирота = tenant-маршрут, который объявлен в App.tsx, но не упомянут
|
||||||
|
* ни в одном из входов и не относится к служебным (login/auth-bridge),
|
||||||
|
* detail-страницам с :id (на них ведут клики по строкам таблицы),
|
||||||
|
* страницам создания /new (на них ведут кнопки «Создать…»).
|
||||||
|
*
|
||||||
|
* Запуск: node tests/scripts/menu-orphans-audit.mjs
|
||||||
|
* Опции: --json — машинный вывод для CI
|
||||||
|
* --out=<file.md> — вместо stdout пишет markdown-отчёт
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
import { resolve, dirname } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
|
const ROOT = resolve(dirname(__filename), '..', '..')
|
||||||
|
const WEB_SRC = resolve(ROOT, 'src/food-market.web/src')
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
app: resolve(WEB_SRC, 'App.tsx'),
|
||||||
|
layout: resolve(WEB_SRC, 'components/AppLayout.tsx'),
|
||||||
|
cmdK: resolve(WEB_SRC, 'components/CommandPalette.tsx'),
|
||||||
|
cmdJ: resolve(WEB_SRC, 'components/QuickActionsPalette.tsx'),
|
||||||
|
supplies: resolve(WEB_SRC, 'pages/SuppliesPage.tsx'),
|
||||||
|
inventories: resolve(WEB_SRC, 'pages/InventoriesPage.tsx'),
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все `path="…"` внутри tenant-ветки (под TenantRouteGuard). */
|
||||||
|
function extractTenantRoutes(appSrc) {
|
||||||
|
const i = appSrc.indexOf('<TenantRouteGuard>')
|
||||||
|
const j = appSrc.indexOf('</Route>', appSrc.indexOf('<Route element={<TenantRouteGuard', i))
|
||||||
|
// Проще — ограничиться концом файла; TenantRouteGuard — последний
|
||||||
|
// крупный блок перед закрывающим */}. Берём от TenantRouteGuard до
|
||||||
|
// конца файла и парсим path'ы — любой "/..." до пробела/>.
|
||||||
|
const slice = appSrc.slice(i)
|
||||||
|
const paths = new Set()
|
||||||
|
const re = /path="([^"]+)"/g
|
||||||
|
let m
|
||||||
|
while ((m = re.exec(slice))) {
|
||||||
|
const p = m[1]
|
||||||
|
if (p.startsWith('/')) paths.add(p)
|
||||||
|
}
|
||||||
|
return [...paths].sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Пути-строки из любого исходника. Regex-ы допускают пустую «часть
|
||||||
|
* после слэша», чтобы ловить и голый `/` (корневой онбординг). */
|
||||||
|
function extractReachablePaths(src) {
|
||||||
|
const out = new Set()
|
||||||
|
// navigate('/foo') / navigate(`/foo`)
|
||||||
|
for (const m of src.matchAll(/navigate\(\s*['"`](\/[^'"`\n]*)['"`]/g)) out.add(m[1])
|
||||||
|
// to="/foo"
|
||||||
|
for (const m of src.matchAll(/\bto="(\/[^"]*)"/g)) out.add(m[1])
|
||||||
|
for (const m of src.matchAll(/\bto=\{\s*['"`](\/[^'"`]*)['"`]\s*\}/g)) out.add(m[1])
|
||||||
|
// href="/foo"
|
||||||
|
for (const m of src.matchAll(/\bhref="(\/[^"]*)"/g)) out.add(m[1])
|
||||||
|
// Поля объектов (SplitButtonMenu / STATIC_PAGES / NavItem).
|
||||||
|
for (const m of src.matchAll(/\bto:\s*['"`](\/[^'"`]*)['"`]/g)) out.add(m[1])
|
||||||
|
for (const m of src.matchAll(/\bpath:\s*['"`](\/[^'"`]*)['"`]/g)) out.add(m[1])
|
||||||
|
// run: (n) => n('/foo')
|
||||||
|
for (const m of src.matchAll(/\bn\(\s*['"`](\/[^'"`]*)['"`]/g)) out.add(m[1])
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Служебные / «вспомогательные» маршруты, которые заведомо не должны
|
||||||
|
* быть пунктом меню. */
|
||||||
|
function isReservedRoute(p) {
|
||||||
|
// Детали редактирования — туда ведут клики по строкам таблиц.
|
||||||
|
if (/\/:[^/]+(\?)?$/.test(p)) return true
|
||||||
|
// «Создать новое …» — туда ведут кнопки «Новый …», это ок.
|
||||||
|
if (p.endsWith('/new')) return true
|
||||||
|
// Явный служебный набор.
|
||||||
|
if (['/login', '/forgot-password', '/reset-password', '/auth-bridge',
|
||||||
|
'/no-organization', '/onboarding-wizard'].includes(p)) return true
|
||||||
|
// SuperAdmin консоль — отдельный layout, у неё свой sidebar.
|
||||||
|
if (p.startsWith('/super-admin')) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const appSrc = readFileSync(files.app, 'utf8')
|
||||||
|
const tenantRoutes = extractTenantRoutes(appSrc)
|
||||||
|
|
||||||
|
const reachable = new Set()
|
||||||
|
for (const key of ['layout', 'cmdK', 'cmdJ', 'supplies', 'inventories']) {
|
||||||
|
const src = readFileSync(files[key], 'utf8')
|
||||||
|
for (const p of extractReachablePaths(src)) reachable.add(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const orphans = []
|
||||||
|
const covered = []
|
||||||
|
const reserved = []
|
||||||
|
|
||||||
|
for (const p of tenantRoutes) {
|
||||||
|
if (isReservedRoute(p)) { reserved.push(p); continue }
|
||||||
|
if (reachable.has(p)) { covered.push(p); continue }
|
||||||
|
// Также засчитываем, если это list-страница, которая достижима через
|
||||||
|
// /... → /.../new (например, когда есть «Создать» на пустой странице,
|
||||||
|
// но нет точки входа на сам список) — это ВСЁ РАВНО сирота: пустую
|
||||||
|
// кнопку создать можно, но вернуться к списку нельзя.
|
||||||
|
orphans.push(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const argv = process.argv.slice(2)
|
||||||
|
const asJson = argv.includes('--json')
|
||||||
|
const outArg = argv.find((a) => a.startsWith('--out='))
|
||||||
|
const outFile = outArg ? outArg.slice('--out='.length) : null
|
||||||
|
|
||||||
|
if (asJson) {
|
||||||
|
const payload = { tenantRoutes, reachable: [...reachable].sort(), orphans, covered, reserved }
|
||||||
|
process.stdout.write(JSON.stringify(payload, null, 2) + '\n')
|
||||||
|
process.exit(orphans.length > 0 ? 2 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = []
|
||||||
|
lines.push('# Menu orphans audit')
|
||||||
|
lines.push('')
|
||||||
|
lines.push(`Generated: ${new Date().toISOString()}`)
|
||||||
|
lines.push('')
|
||||||
|
lines.push(`- tenant-маршрутов всего: **${tenantRoutes.length}**`)
|
||||||
|
lines.push(`- служебных (login / :id / /new / super-admin): **${reserved.length}**`)
|
||||||
|
lines.push(`- достижимо кликами: **${covered.length}**`)
|
||||||
|
lines.push(`- сирот (не достижимо из UI): **${orphans.length}**`)
|
||||||
|
lines.push('')
|
||||||
|
if (orphans.length) {
|
||||||
|
lines.push('## Сироты')
|
||||||
|
lines.push('')
|
||||||
|
for (const p of orphans) lines.push(`- \`${p}\``)
|
||||||
|
lines.push('')
|
||||||
|
} else {
|
||||||
|
lines.push('## Сироты')
|
||||||
|
lines.push('')
|
||||||
|
lines.push('_Нет._ Все не-служебные tenant-маршруты достижимы через sidebar / Cmd+K / Cmd+J / кнопки на list-страницах.')
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
lines.push('## Достижимо')
|
||||||
|
lines.push('')
|
||||||
|
for (const p of covered) lines.push(`- \`${p}\``)
|
||||||
|
lines.push('')
|
||||||
|
lines.push('## Служебное (не требует точки входа)')
|
||||||
|
lines.push('')
|
||||||
|
for (const p of reserved) lines.push(`- \`${p}\``)
|
||||||
|
lines.push('')
|
||||||
|
|
||||||
|
const out = lines.join('\n')
|
||||||
|
if (outFile) {
|
||||||
|
writeFileSync(outFile, out, 'utf8')
|
||||||
|
process.stderr.write(`[orphans] wrote ${outFile} — ${orphans.length} orphan(s)\n`)
|
||||||
|
} else {
|
||||||
|
process.stdout.write(out)
|
||||||
|
}
|
||||||
|
process.exit(orphans.length > 0 ? 2 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
Loading…
Reference in a new issue