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:
parent
0ea2c06731
commit
b82adc414c
35
docs/menu-links-progress.md
Normal file
35
docs/menu-links-progress.md
Normal 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.
|
||||
|
||||
## Протокол выполнения
|
||||
|
||||
(заполняется построчно; номера шагов — из чек-листа выше.)
|
||||
75
docs/menu-orphans-report.md
Normal file
75
docs/menu-orphans-report.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# Menu orphans audit
|
||||
|
||||
Generated: 2026-10-04T17:47:55.836Z
|
||||
|
||||
- tenant-маршрутов всего: **57**
|
||||
- служебных (login / :id / /new / super-admin): **20**
|
||||
- достижимо кликами: **37**
|
||||
- сирот (не достижимо из UI): **0**
|
||||
|
||||
## Сироты
|
||||
|
||||
_Нет._ Все не-служебные tenant-маршруты достижимы через sidebar / Cmd+K / Cmd+J / кнопки на list-страницах.
|
||||
|
||||
## Достижимо
|
||||
|
||||
- `/`
|
||||
- `/admin/diagnostic`
|
||||
- `/admin/import/moysklad`
|
||||
- `/audit-log`
|
||||
- `/catalog/counterparties`
|
||||
- `/catalog/price-types`
|
||||
- `/catalog/product-groups`
|
||||
- `/catalog/products`
|
||||
- `/catalog/retail-points`
|
||||
- `/catalog/stores`
|
||||
- `/catalog/units`
|
||||
- `/dashboard`
|
||||
- `/help`
|
||||
- `/inventory/enters`
|
||||
- `/inventory/inventories`
|
||||
- `/inventory/losses`
|
||||
- `/inventory/mobile`
|
||||
- `/inventory/movements`
|
||||
- `/inventory/stock`
|
||||
- `/inventory/transfers`
|
||||
- `/loyalty/cards`
|
||||
- `/loyalty/programs`
|
||||
- `/promotions`
|
||||
- `/purchases/invoices`
|
||||
- `/purchases/supplier-returns`
|
||||
- `/purchases/supplies`
|
||||
- `/purchases/supplies/scan`
|
||||
- `/reports/abc`
|
||||
- `/reports/profit`
|
||||
- `/reports/sales`
|
||||
- `/reports/stock`
|
||||
- `/sales/demands`
|
||||
- `/sales/retail`
|
||||
- `/settings/employee-roles`
|
||||
- `/settings/employees`
|
||||
- `/settings/organization`
|
||||
- `/whats-new`
|
||||
|
||||
## Служебное (не требует точки входа)
|
||||
|
||||
- `/catalog/products/:id`
|
||||
- `/catalog/products/new`
|
||||
- `/inventory/enters/:id`
|
||||
- `/inventory/enters/new`
|
||||
- `/inventory/inventories/:id`
|
||||
- `/inventory/inventories/new`
|
||||
- `/inventory/losses/:id`
|
||||
- `/inventory/losses/new`
|
||||
- `/inventory/transfers/:id`
|
||||
- `/inventory/transfers/new`
|
||||
- `/onboarding-wizard`
|
||||
- `/purchases/invoices/:id`
|
||||
- `/purchases/supplier-returns/:id`
|
||||
- `/purchases/supplier-returns/new`
|
||||
- `/purchases/supplies/:id`
|
||||
- `/purchases/supplies/new`
|
||||
- `/sales/demands/:id`
|
||||
- `/sales/demands/new`
|
||||
- `/sales/retail/:id`
|
||||
- `/sales/retail/new`
|
||||
|
|
@ -8,6 +8,7 @@ import {
|
|||
LayoutDashboard, Package, FolderTree, Ruler, Tag,
|
||||
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' },
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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') },
|
||||
|
|
|
|||
135
src/food-market.web/src/components/SplitButtonMenu.tsx
Normal file
135
src/food-market.web/src/components/SplitButtonMenu.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { useEffect, useId, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { ChevronDown, Plus } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Dropdown-кнопка «Новая …» с несколькими вариантами входа. Используется
|
||||
* на list-страницах где одна и та же бизнес-операция (приёмка, инвентаризация)
|
||||
* имеет несколько UX-веток: ручной ввод, по фото, сканированием, с телефона.
|
||||
*
|
||||
* Поведение:
|
||||
* - клик по кнопке → раскрывается меню;
|
||||
* - клик по пункту → navigate(to), меню закрывается;
|
||||
* - Esc / click-outside → закрывает;
|
||||
* - стрелки ↑↓ + Enter — клавиатурная навигация внутри меню.
|
||||
*
|
||||
* Из соображений простоты не используем Radix / headlessui — все
|
||||
* list-страницы и так рендерят максимум одну такую кнопку.
|
||||
*/
|
||||
export interface SplitButtonMenuItem {
|
||||
/** Короткий label. */
|
||||
label: string
|
||||
/** Подсказка под label'ом (например, «фото → распознавание»). */
|
||||
hint?: string
|
||||
/** Куда navigate при выборе. */
|
||||
to: string
|
||||
/** Иконка слева (lucide). */
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
/** Тестовый селектор для Playwright. */
|
||||
testId?: string
|
||||
}
|
||||
|
||||
interface SplitButtonMenuProps {
|
||||
/** Label главной кнопки, например «Новая приёмка». */
|
||||
label: string
|
||||
items: SplitButtonMenuItem[]
|
||||
/** Селектор для корневой кнопки (чтобы e2e открывали именно её). */
|
||||
testId?: string
|
||||
}
|
||||
|
||||
export function SplitButtonMenu({ label, items, testId }: SplitButtonMenuProps) {
|
||||
const navigate = useNavigate()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [active, setActive] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const menuId = useId()
|
||||
|
||||
// Click-outside + Esc.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!containerRef.current?.contains(e.target as Node)) setOpen(false)
|
||||
}
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { e.preventDefault(); setOpen(false) }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); setActive(a => Math.min(items.length - 1, a + 1)) }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(a => Math.max(0, a - 1)) }
|
||||
else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const it = items[active]
|
||||
if (it) { setOpen(false); navigate(it.to) }
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', onClick)
|
||||
document.addEventListener('keydown', onKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', onClick)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
}, [open, items, active, navigate])
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative inline-block">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={testId}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-controls={menuId}
|
||||
onClick={() => { setOpen(o => !o); setActive(0) }}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md font-medium transition-colors',
|
||||
'bg-[var(--color-brand)] hover:bg-[var(--color-brand-hover)] text-white',
|
||||
'px-3.5 py-1.5 text-sm',
|
||||
)}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>{label}</span>
|
||||
<ChevronDown className={cn('w-3.5 h-3.5 transition-transform', open && 'rotate-180')} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
id={menuId}
|
||||
role="menu"
|
||||
className={cn(
|
||||
'absolute right-0 mt-1 z-30 min-w-[18rem] rounded-md border bg-white dark:bg-slate-900',
|
||||
'border-slate-200 dark:border-slate-700 shadow-lg overflow-hidden',
|
||||
)}
|
||||
>
|
||||
{items.map((it, i) => {
|
||||
const Icon = it.icon
|
||||
const isActive = i === active
|
||||
return (
|
||||
<button
|
||||
key={it.to + ':' + it.label}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
data-testid={it.testId}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => { setOpen(false); navigate(it.to) }}
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2 flex items-start gap-3 text-sm',
|
||||
isActive
|
||||
? 'bg-emerald-50 dark:bg-emerald-900/30 text-slate-900 dark:text-slate-100'
|
||||
: 'text-slate-700 dark:text-slate-200 hover:bg-slate-50 dark:hover:bg-slate-800/50',
|
||||
)}
|
||||
>
|
||||
{Icon && <Icon className="w-4 h-4 mt-0.5 flex-shrink-0 text-slate-500 dark:text-slate-400" />}
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block font-medium">{it.label}</span>
|
||||
{it.hint && (
|
||||
<span className="block text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||
{it.hint}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -65,7 +65,9 @@
|
|||
"losses": "Write-offs",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -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": "Кассы",
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
210
tests/e2e/scenarios/stage-ui-menu-links.spec.ts
Normal file
210
tests/e2e/scenarios/stage-ui-menu-links.spec.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
163
tests/scripts/menu-orphans-audit.mjs
Executable file
163
tests/scripts/menu-orphans-audit.mjs
Executable file
|
|
@ -0,0 +1,163 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Аудит «сиротских» tenant-маршрутов: читает App.tsx, достаёт все
|
||||
* <Route path="/..."> внутри tenant-блока, сравнивает со всеми строками,
|
||||
* до которых реально можно добраться кликами из UI — через sidebar
|
||||
* (AppLayout.tsx), командную палитру (CommandPalette.tsx), палитру
|
||||
* быстрых действий (QuickActionsPalette.tsx), SplitButtonMenu-экшены
|
||||
* на list-страницах (SuppliesPage.tsx, InventoriesPage.tsx).
|
||||
*
|
||||
* Сирота = tenant-маршрут, который объявлен в App.tsx, но не упомянут
|
||||
* ни в одном из входов и не относится к служебным (login/auth-bridge),
|
||||
* detail-страницам с :id (на них ведут клики по строкам таблицы),
|
||||
* страницам создания /new (на них ведут кнопки «Создать…»).
|
||||
*
|
||||
* Запуск: node tests/scripts/menu-orphans-audit.mjs
|
||||
* Опции: --json — машинный вывод для CI
|
||||
* --out=<file.md> — вместо stdout пишет markdown-отчёт
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve, dirname } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const ROOT = resolve(dirname(__filename), '..', '..')
|
||||
const WEB_SRC = resolve(ROOT, 'src/food-market.web/src')
|
||||
|
||||
const files = {
|
||||
app: resolve(WEB_SRC, 'App.tsx'),
|
||||
layout: resolve(WEB_SRC, 'components/AppLayout.tsx'),
|
||||
cmdK: resolve(WEB_SRC, 'components/CommandPalette.tsx'),
|
||||
cmdJ: resolve(WEB_SRC, 'components/QuickActionsPalette.tsx'),
|
||||
supplies: resolve(WEB_SRC, 'pages/SuppliesPage.tsx'),
|
||||
inventories: resolve(WEB_SRC, 'pages/InventoriesPage.tsx'),
|
||||
}
|
||||
|
||||
/** Все `path="…"` внутри tenant-ветки (под TenantRouteGuard). */
|
||||
function extractTenantRoutes(appSrc) {
|
||||
const i = appSrc.indexOf('<TenantRouteGuard>')
|
||||
const j = appSrc.indexOf('</Route>', appSrc.indexOf('<Route element={<TenantRouteGuard', i))
|
||||
// Проще — ограничиться концом файла; TenantRouteGuard — последний
|
||||
// крупный блок перед закрывающим */}. Берём от TenantRouteGuard до
|
||||
// конца файла и парсим path'ы — любой "/..." до пробела/>.
|
||||
const slice = appSrc.slice(i)
|
||||
const paths = new Set()
|
||||
const re = /path="([^"]+)"/g
|
||||
let m
|
||||
while ((m = re.exec(slice))) {
|
||||
const p = m[1]
|
||||
if (p.startsWith('/')) paths.add(p)
|
||||
}
|
||||
return [...paths].sort()
|
||||
}
|
||||
|
||||
/** Пути-строки из любого исходника. Regex-ы допускают пустую «часть
|
||||
* после слэша», чтобы ловить и голый `/` (корневой онбординг). */
|
||||
function extractReachablePaths(src) {
|
||||
const out = new Set()
|
||||
// navigate('/foo') / navigate(`/foo`)
|
||||
for (const m of src.matchAll(/navigate\(\s*['"`](\/[^'"`\n]*)['"`]/g)) out.add(m[1])
|
||||
// to="/foo"
|
||||
for (const m of src.matchAll(/\bto="(\/[^"]*)"/g)) out.add(m[1])
|
||||
for (const m of src.matchAll(/\bto=\{\s*['"`](\/[^'"`]*)['"`]\s*\}/g)) out.add(m[1])
|
||||
// href="/foo"
|
||||
for (const m of src.matchAll(/\bhref="(\/[^"]*)"/g)) out.add(m[1])
|
||||
// Поля объектов (SplitButtonMenu / STATIC_PAGES / NavItem).
|
||||
for (const m of src.matchAll(/\bto:\s*['"`](\/[^'"`]*)['"`]/g)) out.add(m[1])
|
||||
for (const m of src.matchAll(/\bpath:\s*['"`](\/[^'"`]*)['"`]/g)) out.add(m[1])
|
||||
// run: (n) => n('/foo')
|
||||
for (const m of src.matchAll(/\bn\(\s*['"`](\/[^'"`]*)['"`]/g)) out.add(m[1])
|
||||
return out
|
||||
}
|
||||
|
||||
/** Служебные / «вспомогательные» маршруты, которые заведомо не должны
|
||||
* быть пунктом меню. */
|
||||
function isReservedRoute(p) {
|
||||
// Детали редактирования — туда ведут клики по строкам таблиц.
|
||||
if (/\/:[^/]+(\?)?$/.test(p)) return true
|
||||
// «Создать новое …» — туда ведут кнопки «Новый …», это ок.
|
||||
if (p.endsWith('/new')) return true
|
||||
// Явный служебный набор.
|
||||
if (['/login', '/forgot-password', '/reset-password', '/auth-bridge',
|
||||
'/no-organization', '/onboarding-wizard'].includes(p)) return true
|
||||
// SuperAdmin консоль — отдельный layout, у неё свой sidebar.
|
||||
if (p.startsWith('/super-admin')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function main() {
|
||||
const appSrc = readFileSync(files.app, 'utf8')
|
||||
const tenantRoutes = extractTenantRoutes(appSrc)
|
||||
|
||||
const reachable = new Set()
|
||||
for (const key of ['layout', 'cmdK', 'cmdJ', 'supplies', 'inventories']) {
|
||||
const src = readFileSync(files[key], 'utf8')
|
||||
for (const p of extractReachablePaths(src)) reachable.add(p)
|
||||
}
|
||||
|
||||
const orphans = []
|
||||
const covered = []
|
||||
const reserved = []
|
||||
|
||||
for (const p of tenantRoutes) {
|
||||
if (isReservedRoute(p)) { reserved.push(p); continue }
|
||||
if (reachable.has(p)) { covered.push(p); continue }
|
||||
// Также засчитываем, если это list-страница, которая достижима через
|
||||
// /... → /.../new (например, когда есть «Создать» на пустой странице,
|
||||
// но нет точки входа на сам список) — это ВСЁ РАВНО сирота: пустую
|
||||
// кнопку создать можно, но вернуться к списку нельзя.
|
||||
orphans.push(p)
|
||||
}
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const asJson = argv.includes('--json')
|
||||
const outArg = argv.find((a) => a.startsWith('--out='))
|
||||
const outFile = outArg ? outArg.slice('--out='.length) : null
|
||||
|
||||
if (asJson) {
|
||||
const payload = { tenantRoutes, reachable: [...reachable].sort(), orphans, covered, reserved }
|
||||
process.stdout.write(JSON.stringify(payload, null, 2) + '\n')
|
||||
process.exit(orphans.length > 0 ? 2 : 0)
|
||||
}
|
||||
|
||||
const lines = []
|
||||
lines.push('# Menu orphans audit')
|
||||
lines.push('')
|
||||
lines.push(`Generated: ${new Date().toISOString()}`)
|
||||
lines.push('')
|
||||
lines.push(`- tenant-маршрутов всего: **${tenantRoutes.length}**`)
|
||||
lines.push(`- служебных (login / :id / /new / super-admin): **${reserved.length}**`)
|
||||
lines.push(`- достижимо кликами: **${covered.length}**`)
|
||||
lines.push(`- сирот (не достижимо из UI): **${orphans.length}**`)
|
||||
lines.push('')
|
||||
if (orphans.length) {
|
||||
lines.push('## Сироты')
|
||||
lines.push('')
|
||||
for (const p of orphans) lines.push(`- \`${p}\``)
|
||||
lines.push('')
|
||||
} else {
|
||||
lines.push('## Сироты')
|
||||
lines.push('')
|
||||
lines.push('_Нет._ Все не-служебные tenant-маршруты достижимы через sidebar / Cmd+K / Cmd+J / кнопки на list-страницах.')
|
||||
lines.push('')
|
||||
}
|
||||
lines.push('## Достижимо')
|
||||
lines.push('')
|
||||
for (const p of covered) lines.push(`- \`${p}\``)
|
||||
lines.push('')
|
||||
lines.push('## Служебное (не требует точки входа)')
|
||||
lines.push('')
|
||||
for (const p of reserved) lines.push(`- \`${p}\``)
|
||||
lines.push('')
|
||||
|
||||
const out = lines.join('\n')
|
||||
if (outFile) {
|
||||
writeFileSync(outFile, out, 'utf8')
|
||||
process.stderr.write(`[orphans] wrote ${outFile} — ${orphans.length} orphan(s)\n`)
|
||||
} else {
|
||||
process.stdout.write(out)
|
||||
}
|
||||
process.exit(orphans.length > 0 ? 2 : 0)
|
||||
}
|
||||
|
||||
main()
|
||||
Loading…
Reference in a new issue