feat(menu): новые экраны s31 в sidebar+Cmd+K+Cmd+J + split-кнопки, orphan-audit

Три новых роута из Sprint 31 были достижимы только по прямому URL —
в sidebar/командной палитре их не было. Добавлено:

- AppLayout: «Накладные (по фото)» (/purchases/invoices) в разделе
  «Закупки», «Инвентаризация с телефона» (/inventory/mobile) в
  «Остатках» — для Admin и Storekeeper, видно в простом режиме;
  i18n ru + en.
- SplitButtonMenu — переиспользуемая dropdown-кнопка «Новая…».
  SuppliesPage: вручную / по фото / сканированием. InventoriesPage:
  вручную / с телефона.
- CommandPalette (Cmd+K): новые static-pages invoices, supply-scan,
  inventories (был пропущен), inventory-mobile.
- QuickActionsPalette (Cmd+J): invoice-new, supply-scan,
  inventory-mobile.
- Параллельно всплыл сирота /admin/diagnostic — добавили ссылку
  в sidebar под Admin'ом.

Playwright stage-ui-menu-links.spec.ts: demo-keeper + demo-admin,
desktop 1280×800 + mobile 375×667 — кликами с главной до всех
трёх новых экранов, скриншоты в reports/. Негатив для demo-cashier
проверяет, что ему эти пункты не видны.

Orphan-audit: tests/scripts/menu-orphans-audit.mjs парсит App.tsx и
диффит против фактических точек входа (sidebar/Cmd+K/Cmd+J/split-
кнопок). После всех правок — 0 сирот (отчёт docs/menu-orphans-report.md).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-04 22:50:14 +05:00
parent 0ea2c06731
commit b82adc414c
12 changed files with 680 additions and 12 deletions

View file

@ -0,0 +1,35 @@
# 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.
## Протокол выполнения
(заполняется построчно; номера шагов — из чек-листа выше.)

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -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": "Кассы",

View file

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

View file

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

View file

@ -0,0 +1,210 @@
/**
* 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 — все три новые страницы достижимы кликами', () => {
test.describe.configure({ mode: 'serial' })
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, {
// Диагностика/статусы на орге без всего могут отдавать 404 —
// это НЕ наш баг и допустимо.
expected4xxContains: ['/api/notifications'],
})
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()
}
})
})

View 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()