food-market/src/food-market.web/src/pages/InventoriesPage.tsx
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

94 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useRef } from '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 { SplitButtonMenu } from '@/components/SplitButtonMenu'
import { useCatalogList } from '@/lib/useCatalog'
import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type InventoryListRow, InventoryStatus } from '@/lib/types'
const URL = '/api/inventory/inventories'
export function InventoriesPage() {
const navigate = useNavigate()
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<InventoryListRow>(URL)
const org = useOrgSettings()
const fractional = org.data?.allowFractionalPrices ?? false
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
const searchRef = useRef<HTMLInputElement>(null)
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новую инвентаризацию.
useShortcuts({
'/': () => searchRef.current?.focus(),
'n': () => navigate('/inventory/inventories/new'),
})
return (
<ListPageShell
title="Инвентаризации"
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Пересчёт фактических остатков склада.'}
actions={
<>
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
{/* 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 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
{!isLoading && (data?.items?.length ?? 0) === 0 && !search ? (
<EmptyState
icon={ClipboardList}
title="Инвентаризаций пока нет"
description="Инвентаризация сверяет фактические остатки с книжными и формирует корректировки."
actionLabel="Создать инвентаризацию"
onAction={() => navigate('/inventory/inventories/new')}
/>
) : (
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
sortKey={sortKey}
sortOrder={sortOrder}
onSortChange={setSort}
onRowClick={(r) => navigate(`/inventory/inventories/${r.id}`)}
columns={[
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
r.status === InventoryStatus.Posted
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)},
{ header: 'Склад', cell: (r) => r.storeName },
{ header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount },
{ header: 'Излишек', width: '140px', className: 'text-right font-mono text-green-700', cell: (r) => `+${r.surplusValue.toLocaleString('ru', moneyFmt)}` },
{ header: 'Недостача', width: '140px', className: 'text-right font-mono text-red-700', cell: (r) => `${r.shortageValue.toLocaleString('ru', moneyFmt)}` },
]}
empty="Инвентаризаций пока нет."
/>
)}
</ListPageShell>
)
}