Compare commits
No commits in common. "17374c3943376bc577b04cb4608bd05dc56699e6" and "0ea2c06731abe462af60a0c2540fb15ca6f83a61" have entirely different histories.
17374c3943
...
0ea2c06731
|
|
@ -1,112 +0,0 @@
|
||||||
# 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*` — не трогались.
|
|
||||||
|
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
# 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,7 +8,6 @@ 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'
|
||||||
|
|
@ -113,9 +112,6 @@ 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 })
|
||||||
}
|
}
|
||||||
|
|
@ -124,9 +120,6 @@ 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' },
|
||||||
]})
|
]})
|
||||||
}
|
}
|
||||||
|
|
@ -159,10 +152,6 @@ 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,7 +20,6 @@ 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'
|
||||||
|
|
||||||
|
|
@ -46,11 +45,6 @@ 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,7 +22,6 @@ 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 {
|
||||||
|
|
@ -39,16 +38,11 @@ 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') },
|
||||||
|
|
|
||||||
|
|
@ -1,135 +0,0 @@
|
||||||
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,9 +65,7 @@
|
||||||
"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",
|
||||||
|
|
@ -80,7 +78,6 @@
|
||||||
"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,9 +65,7 @@
|
||||||
"losses": "Списания",
|
"losses": "Списания",
|
||||||
"transfers": "Перемещения",
|
"transfers": "Перемещения",
|
||||||
"inventories": "Инвентаризации",
|
"inventories": "Инвентаризации",
|
||||||
"inventoryMobile": "Инвентаризация с телефона",
|
|
||||||
"supplies": "Приёмки",
|
"supplies": "Приёмки",
|
||||||
"invoices": "Накладные (по фото)",
|
|
||||||
"supplierReturns": "Возвраты поставщикам",
|
"supplierReturns": "Возвраты поставщикам",
|
||||||
"retailSales": "Розничные чеки",
|
"retailSales": "Розничные чеки",
|
||||||
"demands": "Оптовые отгрузки",
|
"demands": "Оптовые отгрузки",
|
||||||
|
|
@ -80,7 +78,6 @@
|
||||||
"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 { useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { ClipboardList, Pencil, Smartphone } from 'lucide-react'
|
import { Plus, ClipboardList } 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 { SplitButtonMenu } from '@/components/SplitButtonMenu'
|
import { Button } from '@/components/Button'
|
||||||
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,18 +37,9 @@ export function InventoriesPage() {
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
|
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
|
||||||
{/* Sprint 31: мобильная инвентаризация — альтернативный вход
|
<Link to="/inventory/inventories/new">
|
||||||
для сверки на складе с телефона. Ручной — старое поведение. */}
|
<Button><Plus className="w-4 h-4" /> Новая инвентаризация</Button>
|
||||||
<SplitButtonMenu
|
</Link>
|
||||||
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 { useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { PackagePlus, Pencil, Camera, ScanLine } from 'lucide-react'
|
import { Plus, PackagePlus } 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 { SplitButtonMenu } from '@/components/SplitButtonMenu'
|
import { Button } from '@/components/Button'
|
||||||
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,21 +39,9 @@ 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" />
|
||||||
{/* Sprint 31: три входа в создание приёмки — ручной, по фото накладной,
|
<Link to="/purchases/supplies/new">
|
||||||
сканированием штрихкодов. Ручной — старое поведение; фото и скан —
|
<Button><Plus className="w-4 h-4" /> Новая приёмка</Button>
|
||||||
новые UX-ветки, в sidebar не продублированы (это именно действия). */}
|
</Link>
|
||||||
<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 && (
|
||||||
|
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
/**
|
|
||||||
* 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()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,163 +0,0 @@
|
||||||
#!/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