feat(web): keyboard shortcuts на edit + list страницах + «?» overlay
Some checks are pending
Some checks are pending
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:
parent
821bc4ed8d
commit
76cbe78257
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
107
src/food-market.web/src/components/ShortcutsOverlay.tsx
Normal file
107
src/food-market.web/src/components/ShortcutsOverlay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
src/food-market.web/src/lib/useShortcuts.ts
Normal file
77
src/food-market.web/src/lib/useShortcuts.ts
Normal 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])
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue