diff --git a/docs/menu-links-progress.md b/docs/menu-links-progress.md new file mode 100644 index 0000000..43ad939 --- /dev/null +++ b/docs/menu-links-progress.md @@ -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. +- Аудит сирот: диффим `` из `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. + +## Протокол выполнения + +(заполняется построчно; номера шагов — из чек-листа выше.) diff --git a/docs/menu-orphans-report.md b/docs/menu-orphans-report.md new file mode 100644 index 0000000..781d7e8 --- /dev/null +++ b/docs/menu-orphans-report.md @@ -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` diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index a02c4f8..41e3700 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -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' }, diff --git a/src/food-market.web/src/components/CommandPalette.tsx b/src/food-market.web/src/components/CommandPalette.tsx index a96cb4a..bc1634a 100644 --- a/src/food-market.web/src/components/CommandPalette.tsx +++ b/src/food-market.web/src/components/CommandPalette.tsx @@ -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 & { 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' }, diff --git a/src/food-market.web/src/components/QuickActionsPalette.tsx b/src/food-market.web/src/components/QuickActionsPalette.tsx index fce2ba3..c69cce8 100644 --- a/src/food-market.web/src/components/QuickActionsPalette.tsx +++ b/src/food-market.web/src/components/QuickActionsPalette.tsx @@ -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') }, diff --git a/src/food-market.web/src/components/SplitButtonMenu.tsx b/src/food-market.web/src/components/SplitButtonMenu.tsx new file mode 100644 index 0000000..622e12a --- /dev/null +++ b/src/food-market.web/src/components/SplitButtonMenu.tsx @@ -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(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 ( +
+ + + {open && ( + + )} +
+ ) +} diff --git a/src/food-market.web/src/locales/en.json b/src/food-market.web/src/locales/en.json index 8f842a6..bae5942 100644 --- a/src/food-market.web/src/locales/en.json +++ b/src/food-market.web/src/locales/en.json @@ -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", diff --git a/src/food-market.web/src/locales/ru.json b/src/food-market.web/src/locales/ru.json index 7f09a87..fb77c59 100644 --- a/src/food-market.web/src/locales/ru.json +++ b/src/food-market.web/src/locales/ru.json @@ -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": "Кассы", diff --git a/src/food-market.web/src/pages/InventoriesPage.tsx b/src/food-market.web/src/pages/InventoriesPage.tsx index 45ce6f1..ccec279 100644 --- a/src/food-market.web/src/pages/InventoriesPage.tsx +++ b/src/food-market.web/src/pages/InventoriesPage.tsx @@ -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={ <> - - - + {/* Sprint 31: мобильная инвентаризация — альтернативный вход + для сверки на складе с телефона. Ручной — старое поведение. */} + } footer={data && data.total > 0 && ( diff --git a/src/food-market.web/src/pages/SuppliesPage.tsx b/src/food-market.web/src/pages/SuppliesPage.tsx index 495ab44..6bca660 100644 --- a/src/food-market.web/src/pages/SuppliesPage.tsx +++ b/src/food-market.web/src/pages/SuppliesPage.tsx @@ -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() { <> - - - + {/* Sprint 31: три входа в создание приёмки — ручной, по фото накладной, + сканированием штрихкодов. Ручной — старое поведение; фото и скан — + новые UX-ветки, в sidebar не продублированы (это именно действия). */} + } footer={data && data.total > 0 && ( diff --git a/tests/e2e/scenarios/stage-ui-menu-links.spec.ts b/tests/e2e/scenarios/stage-ui-menu-links.spec.ts new file mode 100644 index 0000000..f2cf9be --- /dev/null +++ b/tests/e2e/scenarios/stage-ui-menu-links.spec.ts @@ -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 { + 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 —