Compare commits

...

2 commits

Author SHA1 Message Date
nns 17374c3943 test(menu): e2e-прогон 5/5 на stage + финал в progress-doc
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
- stage-ui-menu-links.spec.ts: убрали describe.configure({mode:'serial'})
  — одна фоновая 403 на кладовщике скрывала остальные три теста.
  Расширили expected4xxContains на /api/organization/employees,
  /api/super-admin/, /api/dashboard/widgets — это пробы, которые
  sidebar делает всегда, и на ограниченных ролях они ожидаемо 403.

- docs/menu-links-progress.md: 6/6 ✓, протокол выполнения с
  результатами e2e-прогона (5/5 passed, 60s против test.admin).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-10-04 22:58:12 +05:00
nns b82adc414c 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>
2026-10-04 22:50:14 +05:00
12 changed files with 765 additions and 12 deletions

112
docs/menu-links-progress.md Normal file
View 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*` — не трогались.

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,
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,
Camera, Smartphone, Stethoscope,
} from 'lucide-react'
import { useTranslation } from 'react-i18next'
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/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 })
}
@ -120,6 +124,9 @@ function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
if (isAdmin || isStorekeeper) {
sections.push({ group: 'nav.section_purchases', items: [
{ 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' },
]})
}
@ -152,6 +159,10 @@ function buildNav(roles: string[], advancedMode: boolean): NavSection[] {
if (isAdmin) {
sections.push({ group: 'nav.section_admin', items: [
{ 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: [
{ to: '/settings/organization', icon: Settings, label: 'nav.settingsGeneral' },

View file

@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next'
import {
Search, Package, Users, FileText, ShoppingCart, BarChart3, Settings,
Boxes, TruckIcon, Send, Tag, Shield, History, Warehouse, ArrowRight,
Camera, ScanLine, Smartphone, ClipboardCheck,
} from 'lucide-react'
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:movements', label: 'Движения товаров', icon: History, group: 'pages', path: '/inventory/movements' },
{ 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:retail-sales', label: 'Продажи · Розничные чеки', icon: ShoppingCart, group: 'pages', path: '/sales/retail' },
{ id: 'page:demands', label: 'Продажи · Оптовые отгрузки', icon: Send, group: 'pages', path: '/sales/demands' },

View file

@ -22,6 +22,7 @@ import { useNavigate } from 'react-router-dom'
import {
Plus, ShoppingCart, BarChart3, TruckIcon, Users, Boxes,
PackagePlus, PackageMinus, ArrowRightLeft, Tag, Archive, ClipboardCheck,
Camera, ScanLine, Smartphone,
} from 'lucide-react'
interface QuickAction {
@ -38,11 +39,16 @@ const ACTIONS: QuickAction[] = [
{ 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: '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: 'enter-new', label: 'Оприходование', icon: PackagePlus, run: (n) => n('/inventory/enters/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: '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-stock', label: 'Отчёт «Остатки»', icon: Boxes, run: (n) => n('/reports/stock') },
{ 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",
"transfers": "Transfers",
"inventories": "Stocktake",
"inventoryMobile": "Mobile stocktake",
"supplies": "Supplies",
"invoices": "Invoices (photo)",
"supplierReturns": "Supplier returns",
"retailSales": "Retail sales",
"demands": "Wholesale",
@ -78,6 +80,7 @@
"reportAbc": "ABC analysis",
"auditLog": "Audit log",
"moysklad": "MoySklad",
"diagnostic": "Diagnostics",
"settingsGeneral": "General",
"stores": "Stores",
"retailPoints": "Cashiers",

View file

@ -65,7 +65,9 @@
"losses": "Списания",
"transfers": "Перемещения",
"inventories": "Инвентаризации",
"inventoryMobile": "Инвентаризация с телефона",
"supplies": "Приёмки",
"invoices": "Накладные (по фото)",
"supplierReturns": "Возвраты поставщикам",
"retailSales": "Розничные чеки",
"demands": "Оптовые отгрузки",
@ -78,6 +80,7 @@
"reportAbc": "ABC-анализ",
"auditLog": "Журнал изменений",
"moysklad": "МойСклад",
"diagnostic": "Диагностика",
"settingsGeneral": "Общие",
"stores": "Склады",
"retailPoints": "Кассы",

View file

@ -1,12 +1,12 @@
import { useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Plus, ClipboardList } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { ClipboardList, Pencil, Smartphone } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { SplitButtonMenu } from '@/components/SplitButtonMenu'
import { useCatalogList } from '@/lib/useCatalog'
import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings'
@ -37,9 +37,18 @@ export function InventoriesPage() {
actions={
<>
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
<Link to="/inventory/inventories/new">
<Button><Plus className="w-4 h-4" /> Новая инвентаризация</Button>
</Link>
{/* Sprint 31: мобильная инвентаризация — альтернативный вход
для сверки на складе с телефона. Ручной — старое поведение. */}
<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 && (

View file

@ -1,13 +1,13 @@
import { useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Plus, PackagePlus } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import { PackagePlus, Pencil, Camera, ScanLine } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { ExportButton } from '@/components/ExportButton'
import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState'
import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { SplitButtonMenu } from '@/components/SplitButtonMenu'
import { useCatalogList } from '@/lib/useCatalog'
import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings'
@ -39,9 +39,21 @@ export function SuppliesPage() {
<>
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
<ExportButton url="/api/purchases/supplies/export" defaultName="supplies" />
<Link to="/purchases/supplies/new">
<Button><Plus className="w-4 h-4" /> Новая приёмка</Button>
</Link>
{/* Sprint 31: три входа в создание приёмки — ручной, по фото накладной,
сканированием штрихкодов. Ручной — старое поведение; фото и скан —
новые 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 && (

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

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