feat(web): keyboard shortcuts на edit + list страницах + «?» overlay
Some checks are pending
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

Item 7 Sprint 7 — финальный пункт.

Хук: src/lib/useShortcuts.ts — поддерживает 'mod+s' (Ctrl/Cmd), Escape,
одиночные клавиши ('/', 'n', '?'). Бэр-клавиши скипают input/textarea/
contenteditable чтобы не ломать ввод. preventDefault() автоматически,
второй параметр enabled=true для конфликтов с диалогами.

Edit-страницы (9: Product + 8 doc-edit):
- mod+s = save (через canSave/canSubmit и save.isPending)
- Escape = navigate(<list-path>)
- enabled = !dialogProps.open — чтобы Esc не пересекался с ConfirmDialog
  (иначе Esc бы и закрыл диалог, и навигировал на список).

List-страницы (10: Products + 9 doc-list):
- '/' = searchRef.current?.focus()
- 'n' = navigate('/<entity>/new')
  (на CounterpartiesPage — открыть create-modal, т.к. там нет роута)
- SearchBar переведён на forwardRef для ref-проброса в input.

«?»-Overlay: src/components/ShortcutsOverlay.tsx — глобальный модал со
шпаргалкой. Открывается '?', закрывается Esc или кликом снаружи.
Смонтирован в AppLayout один раз.

tsc clean. На стейдже задеплоено.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-30 11:41:54 +05:00
parent 821bc4ed8d
commit 76cbe78257
23 changed files with 379 additions and 14 deletions

View file

@ -11,6 +11,7 @@ import {
} from 'lucide-react'
import { Logo } from './Logo'
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
import { ShortcutsOverlay } from './ShortcutsOverlay'
interface MeResponse {
sub: string
@ -269,6 +270,9 @@ export function AppLayout() {
<SuperAdminAsOrgBanner />
<Outlet />
</main>
{/* Глобальный «?»-оверлей со списком горячих клавиш. Сам обрабатывает
'Esc' и '?', не блокирует ввод в инпутах. */}
<ShortcutsOverlay />
</div>
)
}

View file

@ -1,3 +1,4 @@
import { forwardRef } from 'react'
import { Search } from 'lucide-react'
interface SearchBarProps {
@ -6,11 +7,19 @@ interface SearchBarProps {
placeholder?: string
}
export function SearchBar({ value, onChange, placeholder = 'Поиск…' }: SearchBarProps) {
/**
* Поиск с иконкой. forwardRef нужен, чтобы list-страница могла фокуснуть
* input по хоткею `/` (см. useShortcuts на ProductsPage и др.).
*/
export const SearchBar = forwardRef<HTMLInputElement, SearchBarProps>(function SearchBar(
{ value, onChange, placeholder = 'Поиск…' },
ref,
) {
return (
<div className="relative flex-1 min-w-[180px] sm:flex-none">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
ref={ref}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
@ -19,4 +28,4 @@ export function SearchBar({ value, onChange, placeholder = 'Поиск…' }: Se
/>
</div>
)
}
})

View file

@ -0,0 +1,107 @@
import { useEffect, useState } from 'react'
import { Keyboard, X } from 'lucide-react'
import { useShortcuts } from '@/lib/useShortcuts'
/**
* Глобальный «?» overlay открывается клавишей `?`. Показывает текущие
* горячие клавиши приложения: ничего hierarchical, просто плоский список
* по разделам (Edit-страницы / List-страницы / Глобально).
*
* Маунтим один раз в AppLayout, чтобы был доступен везде внутри tenant'a.
*/
export function ShortcutsOverlay() {
const [open, setOpen] = useState(false)
useShortcuts({
'?': () => setOpen(o => !o),
'Escape': () => { if (open) setOpen(false) },
})
// Закрытие по клику на оверлей
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false) }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open])
if (!open) return null
return (
<div
className="fixed inset-0 z-[70] flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm"
onClick={() => setOpen(false)}
role="dialog"
aria-modal="true"
aria-labelledby="shortcuts-title"
>
<div
className="w-full max-w-lg bg-white dark:bg-slate-900 rounded-xl shadow-xl flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-200 dark:border-slate-800">
<div className="flex items-center gap-2">
<Keyboard className="w-4 h-4 text-slate-500" />
<h2 id="shortcuts-title" className="font-semibold">Горячие клавиши</h2>
</div>
<button
onClick={() => setOpen(false)}
className="text-slate-400 hover:text-slate-600"
aria-label="Закрыть"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="px-5 py-4 space-y-5 text-sm">
<Section title="На любой странице">
<Row label="Показать/скрыть подсказку" keys={['?']} />
<Row label="Закрыть диалог / отменить" keys={['Esc']} />
</Section>
<Section title="Списки (Товары, Контрагенты, Документы)">
<Row label="Фокус в поиск" keys={['/']} />
<Row label="Создать новый" keys={['N']} />
</Section>
<Section title="Edit-формы (Товар, Документ)">
<Row label="Сохранить" keys={['Ctrl', 'S']} />
<Row label="Назад к списку" keys={['Esc']} />
</Section>
</div>
<div className="px-5 py-2 border-t border-slate-200 dark:border-slate-800 text-[11px] text-slate-400">
Нажми <Kbd>?</Kbd> в любой момент, чтобы открыть эту шпаргалку.
</div>
</div>
</div>
)
}
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div>
<h3 className="text-xs uppercase tracking-wide text-slate-500 mb-2">{title}</h3>
<div className="space-y-1.5">{children}</div>
</div>
)
}
function Row({ label, keys }: { label: string; keys: string[] }) {
return (
<div className="flex items-center justify-between gap-3">
<span className="text-slate-700 dark:text-slate-300">{label}</span>
<span className="flex items-center gap-1 shrink-0">
{keys.map((k, i) => (
<span key={i} className="flex items-center gap-1">
{i > 0 && <span className="text-slate-400 text-xs">+</span>}
<Kbd>{k}</Kbd>
</span>
))}
</span>
</div>
)
}
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-block px-1.5 py-0.5 rounded border border-slate-300 dark:border-slate-600 bg-slate-50 dark:bg-slate-800 text-xs font-mono shadow-[inset_0_-1px_0_0_rgb(0_0_0_/_0.08)]">
{children}
</kbd>
)
}

View file

@ -0,0 +1,77 @@
import { useEffect, useRef } from 'react'
/**
* Регистрирует глобальные клавиатурные сокращения. Поддерживаемые формы:
* - 'Escape' | 'Enter' | 'F1'
* - 'mod+s' Ctrl+S на Linux/Win, Cmd+S на Mac
* - 'mod+shift+s'
* - 'n', '/', '?' (одиночные клавиши БЕЗ модификаторов)
*
* Особенности:
* - Одиночные клавиши срабатывают только если фокус НЕ в input/textarea/
* contenteditable (чтобы не ломать набор текста). 'Escape' и mod+*
* срабатывают всегда.
* - preventDefault() вызывается автоматически handler решает только что
* делать, без боли с e.preventDefault().
* - При unmount хук снимает listener.
*
* Использование:
* useShortcuts({
* 'mod+s': () => save.mutate(),
* 'Escape': () => navigate(-1),
* '/': () => searchRef.current?.focus(),
* 'n': () => navigate('/catalog/products/new'),
* })
*/
type Handler = (e: KeyboardEvent) => void
type Map = Record<string, Handler>
const isTypingTarget = (el: EventTarget | null): boolean => {
if (!(el instanceof HTMLElement)) return false
const tag = el.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
if (el.isContentEditable) return true
return false
}
const matches = (e: KeyboardEvent, spec: string): boolean => {
const parts = spec.toLowerCase().split('+').map(s => s.trim())
const wantMod = parts.includes('mod')
const wantShift = parts.includes('shift')
const wantAlt = parts.includes('alt')
const key = parts[parts.length - 1]
const ctrlOrMeta = e.ctrlKey || e.metaKey
if (wantMod !== ctrlOrMeta) return false
if (wantShift !== e.shiftKey) return false
if (wantAlt !== e.altKey) return false
// 'mod+s' → e.key === 's' (lowercase)
return e.key.toLowerCase() === key
}
export function useShortcuts(map: Map, enabled = true) {
// Держим map в ref, чтобы не перерегистрировать listener на каждый рендер
// (handlers часто захватывают свежие props/state — но useEffect deps
// делает их стабильными).
const ref = useRef(map)
ref.current = map
useEffect(() => {
if (!enabled) return
const onKey = (e: KeyboardEvent) => {
for (const [spec, handler] of Object.entries(ref.current)) {
const isBareKey = !spec.includes('+') && spec.length === 1
// Бэр-клавиши ('n', '/', '?') не должны срабатывать в input'ах.
if (isBareKey && isTypingTarget(e.target)) continue
if (matches(e, spec)) {
e.preventDefault()
handler(e)
return
}
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [enabled])
}

View file

@ -1,6 +1,7 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { validateEmail, validatePhone } from '@/lib/validation'
import { useShortcuts } from '@/lib/useShortcuts'
import { Plus, Trash2, Users } from 'lucide-react'
import { api } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell'
@ -57,6 +58,13 @@ export function CounterpartiesPage() {
const [form, setForm] = useState<Form | null>(null)
const [fieldErrors, setFieldErrors] = useState<Partial<Record<'name' | 'email' | 'phone', string>>>({})
const { confirm, dialogProps } = useConfirm()
const searchRef = useRef<HTMLInputElement>(null)
// Хоткеи list-страницы: `/` фокусит поиск, `n` — открыть модалку «новый контрагент».
useShortcuts({
'/': () => searchRef.current?.focus(),
'n': () => { setForm(blankForm); setFieldErrors({}) },
})
const countries = useQuery({
queryKey: ['countries-lookup'],
@ -80,7 +88,7 @@ export function CounterpartiesPage() {
description="Поставщики и покупатели."
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="Поиск по имени, БИН, ИИН, телефону…" />
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="Поиск по имени, БИН, ИИН, телефону…" />
<Button onClick={() => { setForm(blankForm); setFieldErrors({}) }}><Plus className="w-4 h-4" /> Добавить</Button>
</>
}

View file

@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useShortcuts } from '@/lib/useShortcuts'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import {
@ -211,6 +212,13 @@ export function DemandEditPage() {
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
// Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить, Esc = назад к списку.
// Esc отключается когда открыт ConfirmDialog.
useShortcuts({
'mod+s': () => { if (canSave && !save.isPending) save.mutate() },
'Escape': () => navigate('/sales/demands'),
}, !dialogProps.open)
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />

View file

@ -1,3 +1,4 @@
import { useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Plus, Truck } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type DemandListRow, DemandStatus, demandPaymentLabel } from '@/lib/types'
@ -20,6 +22,13 @@ export function DemandsPage() {
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
const searchRef = useRef<HTMLInputElement>(null)
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новую отгрузку.
useShortcuts({
'/': () => searchRef.current?.focus(),
'n': () => navigate('/sales/demands/new'),
})
return (
<ListPageShell
@ -27,7 +36,7 @@ export function DemandsPage() {
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Отгрузка товара юрлицу-контрагенту.'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру или контрагенту…" />
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру или контрагенту…" />
<Link to="/sales/demands/new">
<Button><Plus className="w-4 h-4" /> Новая отгрузка</Button>
</Link>

View file

@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useShortcuts } from '@/lib/useShortcuts'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { EnterStatus, type EnterDto, type Product } from '@/lib/types'
@ -191,6 +192,13 @@ export function EnterEditPage() {
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
// Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить, Esc = назад к списку.
// Esc отключается когда открыт ConfirmDialog.
useShortcuts({
'mod+s': () => { if (canSave && !save.isPending) save.mutate() },
'Escape': () => navigate('/inventory/enters'),
}, !dialogProps.open)
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />

View file

@ -1,3 +1,4 @@
import { useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Plus, PackagePlus } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type EnterListRow, EnterStatus } from '@/lib/types'
@ -20,6 +22,13 @@ export function EntersPage() {
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
const searchRef = useRef<HTMLInputElement>(null)
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новое оприходование.
useShortcuts({
'/': () => searchRef.current?.focus(),
'n': () => navigate('/inventory/enters/new'),
})
return (
<ListPageShell
@ -27,7 +36,7 @@ export function EntersPage() {
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Постановка товара на склад без поставщика — начальные остатки и излишки.'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру…" />
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
<Link to="/inventory/enters/new">
<Button><Plus className="w-4 h-4" /> Новое оприходование</Button>
</Link>

View file

@ -1,3 +1,4 @@
import { useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Plus, ClipboardList } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type InventoryListRow, InventoryStatus } from '@/lib/types'
@ -20,6 +22,13 @@ export function InventoriesPage() {
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
@ -27,7 +36,7 @@ export function InventoriesPage() {
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Пересчёт фактических остатков склада.'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру…" />
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
<Link to="/inventory/inventories/new">
<Button><Plus className="w-4 h-4" /> Новая инвентаризация</Button>
</Link>

View file

@ -10,6 +10,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useShortcuts } from '@/lib/useShortcuts'
import { useStores } from '@/lib/useLookups'
import { InventoryStatus, type InventoryDto } from '@/lib/types'
@ -207,6 +208,18 @@ export function InventoryEditPage() {
const canPost = isDraft && form.lines.some((l) => l.diff !== 0) && !isNew
// Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить (через create на новой, save на
// существующей), Esc = назад к списку. Esc отключается когда открыт ConfirmDialog.
const canSubmit = isDraft && !!form.storeId && !create.isPending && !save.isPending
useShortcuts({
'mod+s': () => {
if (!canSubmit) return
if (isNew) create.mutate()
else save.mutate()
},
'Escape': () => navigate('/inventory/inventories'),
}, !dialogProps.open)
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />

View file

@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useShortcuts } from '@/lib/useShortcuts'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { LossStatus, LossReason, lossReasonLabel, type LossDto, type Product } from '@/lib/types'
@ -194,6 +195,13 @@ export function LossEditPage() {
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
// Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить, Esc = назад к списку.
// Esc отключается когда открыт ConfirmDialog.
useShortcuts({
'mod+s': () => { if (canSave && !save.isPending) save.mutate() },
'Escape': () => navigate('/inventory/losses'),
}, !dialogProps.open)
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />

View file

@ -1,3 +1,4 @@
import { useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Plus, Trash2 } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type LossListRow, LossStatus, lossReasonLabel } from '@/lib/types'
@ -20,6 +22,13 @@ export function LossesPage() {
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
const searchRef = useRef<HTMLInputElement>(null)
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новое списание.
useShortcuts({
'/': () => searchRef.current?.focus(),
'n': () => navigate('/inventory/losses/new'),
})
return (
<ListPageShell
@ -27,7 +36,7 @@ export function LossesPage() {
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Брак, просрочка, повреждения, недостача.'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру…" />
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
<Link to="/inventory/losses/new">
<Button><Plus className="w-4 h-4" /> Новое списание</Button>
</Link>

View file

@ -15,6 +15,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useShortcuts } from '@/lib/useShortcuts'
import { generateEan13InternalPrefix2, generateBarcode, generateArticle } from '@/lib/barcode'
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
@ -222,6 +223,15 @@ export function ProductEditPage() {
&& form.barcodes.length > 0
&& missingRequiredPrices.length === 0
// Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить, Esc = назад к списку.
// Срабатывают даже из инпутов (mod+s) и из любого фокуса (Escape).
// enabled=false когда открыт ConfirmDialog — иначе Esc уйдёт двум хендлерам
// и навигация перевесит закрытие диалога.
useShortcuts({
'mod+s': () => { if (canSave && !save.isPending) save.mutate() },
'Escape': () => navigate('/catalog/products'),
}, !dialogProps.open)
// На редактировании пока тащим существующий товар — показываем скелет
// вместо пустых полей формы, чтобы не путать пользователя.
if (!isNew && existing.isLoading) return <FormSkeleton />

View file

@ -1,4 +1,4 @@
import { useState } from 'react'
import { useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { DataTable } from '@/components/DataTable'
import { EmptyState } from '@/components/EmptyState'
@ -7,6 +7,7 @@ import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { Plus, Filter, X, FolderTree, Package } from 'lucide-react'
import { useCatalogList } from '@/lib/useCatalog'
import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { usePriceTypes } from '@/lib/useLookups'
import { ProductGroupTree } from '@/components/ProductGroupTree'
@ -108,6 +109,13 @@ export function ProductsPage() {
const showMarked = org.data?.showMarkedOnProduct ?? false
const activeCount = activeFilterCount(filters)
const [groupsOpen, setGroupsOpen] = useState(false)
const searchRef = useRef<HTMLInputElement>(null)
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новый товар.
useShortcuts({
'/': () => searchRef.current?.focus(),
'n': () => navigate('/catalog/products/new'),
})
type Col = {
header: string
@ -213,7 +221,7 @@ export function ProductsPage() {
</p>
</div>
<div className="ml-auto flex flex-wrap items-center gap-2">
<SearchBar value={search} onChange={setSearch} placeholder="Поиск: название, артикул, штрихкод…" />
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="Поиск: название, артикул, штрихкод…" />
<Button
variant={filtersOpen || activeCount ? 'primary' : 'secondary'}
onClick={() => setFiltersOpen((v) => !v)}

View file

@ -10,6 +10,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useShortcuts } from '@/lib/useShortcuts'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { RetailSaleStatus, PaymentMethod, type RetailSaleDto, type Product } from '@/lib/types'
@ -210,6 +211,13 @@ export function RetailSaleEditPage() {
// кнопка disabled с подсказкой.
const canSave = !!form.storeId && !!form.currencyId && isDraft && form.lines.length > 0
// Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить, Esc = назад к списку.
// Esc отключается когда открыт ConfirmDialog.
useShortcuts({
'mod+s': () => { if (canSave && !save.isPending) save.mutate() },
'Escape': () => navigate('/sales/retail'),
}, !dialogProps.open)
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />

View file

@ -1,3 +1,4 @@
import { useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Plus, Receipt } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type RetailSaleListRow, RetailSaleStatus, PaymentMethod } from '@/lib/types'
@ -28,6 +30,13 @@ export function RetailSalesPage() {
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
const searchRef = useRef<HTMLInputElement>(null)
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новый чек.
useShortcuts({
'/': () => searchRef.current?.focus(),
'n': () => navigate('/sales/retail/new'),
})
return (
<ListPageShell
@ -35,7 +44,7 @@ export function RetailSalesPage() {
description={data ? `${data.total.toLocaleString('ru')} чеков` : 'Чеки розничных продаж'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру чека…" />
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру чека…" />
<Link to="/sales/retail/new">
<Button><Plus className="w-4 h-4" /> Новый чек</Button>
</Link>

View file

@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useShortcuts } from '@/lib/useShortcuts'
import { useStores, useCurrencies } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { SupplierReturnStatus, type SupplierReturnDto, type Product } from '@/lib/types'
@ -197,6 +198,13 @@ export function SupplierReturnEditPage() {
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
// Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить, Esc = назад к списку.
// Esc отключается когда открыт ConfirmDialog.
useShortcuts({
'mod+s': () => { if (canSave && !save.isPending) save.mutate() },
'Escape': () => navigate('/purchases/supplier-returns'),
}, !dialogProps.open)
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />

View file

@ -1,3 +1,4 @@
import { useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Plus, Undo2 } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type SupplierReturnListRow, SupplierReturnStatus } from '@/lib/types'
@ -20,6 +22,13 @@ export function SupplierReturnsPage() {
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
const searchRef = useRef<HTMLInputElement>(null)
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новый возврат поставщику.
useShortcuts({
'/': () => searchRef.current?.focus(),
'n': () => navigate('/purchases/supplier-returns/new'),
})
return (
<ListPageShell
@ -27,7 +36,7 @@ export function SupplierReturnsPage() {
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Возврат поставщику (брак, излишек, неликвид).'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
<Link to="/purchases/supplier-returns/new">
<Button><Plus className="w-4 h-4" /> Новый возврат</Button>
</Link>

View file

@ -1,3 +1,4 @@
import { useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Plus, PackagePlus } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type SupplyListRow, SupplyStatus } from '@/lib/types'
@ -20,6 +22,13 @@ export function SuppliesPage() {
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
const searchRef = useRef<HTMLInputElement>(null)
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новую приёмку.
useShortcuts({
'/': () => searchRef.current?.focus(),
'n': () => navigate('/purchases/supplies/new'),
})
return (
<ListPageShell
@ -27,7 +36,7 @@ export function SuppliesPage() {
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Документы поступления товара от поставщиков'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
<Link to="/purchases/supplies/new">
<Button><Plus className="w-4 h-4" /> Новая приёмка</Button>
</Link>

View file

@ -12,6 +12,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useShortcuts } from '@/lib/useShortcuts'
import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
@ -270,6 +271,14 @@ export function SupplyEditPage() {
&& form.lines.length > 0
&& isDraft
// Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить, Esc = назад к списку.
// Esc отключается когда открыт ConfirmDialog, чтобы не закрыть диалог
// и одновременно не уйти на список.
useShortcuts({
'mod+s': () => { if (canSave && !save.isPending) save.mutate() },
'Escape': () => navigate('/purchases/supplies'),
}, !dialogProps.open)
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />

View file

@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
import { FormSkeleton } from '@/components/Skeleton'
import { Breadcrumbs } from '@/components/Breadcrumbs'
import { useConfirm } from '@/lib/useConfirm'
import { useShortcuts } from '@/lib/useShortcuts'
import { useStores } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { TransferStatus, type TransferDto, type Product } from '@/lib/types'
@ -179,6 +180,13 @@ export function TransferEditPage() {
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
// Хоткеи edit-страницы: Ctrl/Cmd+S = сохранить, Esc = назад к списку.
// Esc отключается когда открыт ConfirmDialog.
useShortcuts({
'mod+s': () => { if (canSave && !save.isPending) save.mutate() },
'Escape': () => navigate('/inventory/transfers'),
}, !dialogProps.open)
// На редактировании пока тащим документ — показываем скелет.
if (!isNew && existing.isLoading) return <FormSkeleton />

View file

@ -1,3 +1,4 @@
import { useRef } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Plus, ArrowRight, ArrowLeftRight } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
import { useCatalogList } from '@/lib/useCatalog'
import { useShortcuts } from '@/lib/useShortcuts'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type TransferListRow, TransferStatus } from '@/lib/types'
@ -20,6 +22,13 @@ export function TransfersPage() {
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
const searchRef = useRef<HTMLInputElement>(null)
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новое перемещение.
useShortcuts({
'/': () => searchRef.current?.focus(),
'n': () => navigate('/inventory/transfers/new'),
})
return (
<ListPageShell
@ -27,7 +36,7 @@ export function TransfersPage() {
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Перевод товара между складами.'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру…" />
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
<Link to="/inventory/transfers/new">
<Button><Plus className="w-4 h-4" /> Новое перемещение</Button>
</Link>