Три новых роута из 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>
94 lines
4.9 KiB
TypeScript
94 lines
4.9 KiB
TypeScript
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>
|
||
)
|
||
}
|