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'
|
} from 'lucide-react'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
|
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
|
||||||
|
import { ShortcutsOverlay } from './ShortcutsOverlay'
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
sub: string
|
sub: string
|
||||||
|
|
@ -269,6 +270,9 @@ export function AppLayout() {
|
||||||
<SuperAdminAsOrgBanner />
|
<SuperAdminAsOrgBanner />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
{/* Глобальный «?»-оверлей со списком горячих клавиш. Сам обрабатывает
|
||||||
|
'Esc' и '?', не блокирует ввод в инпутах. */}
|
||||||
|
<ShortcutsOverlay />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { forwardRef } from 'react'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
interface SearchBarProps {
|
interface SearchBarProps {
|
||||||
|
|
@ -6,11 +7,19 @@ interface SearchBarProps {
|
||||||
placeholder?: string
|
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 (
|
return (
|
||||||
<div className="relative flex-1 min-w-[180px] sm:flex-none">
|
<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" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
|
ref={ref}
|
||||||
type="text"
|
type="text"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
|
@ -19,4 +28,4 @@ export function SearchBar({ value, onChange, placeholder = 'Поиск…' }: Se
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 { useQuery } from '@tanstack/react-query'
|
||||||
import { validateEmail, validatePhone } from '@/lib/validation'
|
import { validateEmail, validatePhone } from '@/lib/validation'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { Plus, Trash2, Users } from 'lucide-react'
|
import { Plus, Trash2, Users } from 'lucide-react'
|
||||||
import { api } from '@/lib/api'
|
import { api } from '@/lib/api'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
|
@ -57,6 +58,13 @@ export function CounterpartiesPage() {
|
||||||
const [form, setForm] = useState<Form | null>(null)
|
const [form, setForm] = useState<Form | null>(null)
|
||||||
const [fieldErrors, setFieldErrors] = useState<Partial<Record<'name' | 'email' | 'phone', string>>>({})
|
const [fieldErrors, setFieldErrors] = useState<Partial<Record<'name' | 'email' | 'phone', string>>>({})
|
||||||
const { confirm, dialogProps } = useConfirm()
|
const { confirm, dialogProps } = useConfirm()
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Хоткеи list-страницы: `/` фокусит поиск, `n` — открыть модалку «новый контрагент».
|
||||||
|
useShortcuts({
|
||||||
|
'/': () => searchRef.current?.focus(),
|
||||||
|
'n': () => { setForm(blankForm); setFieldErrors({}) },
|
||||||
|
})
|
||||||
|
|
||||||
const countries = useQuery({
|
const countries = useQuery({
|
||||||
queryKey: ['countries-lookup'],
|
queryKey: ['countries-lookup'],
|
||||||
|
|
@ -80,7 +88,7 @@ export function CounterpartiesPage() {
|
||||||
description="Поставщики и покупатели."
|
description="Поставщики и покупатели."
|
||||||
actions={
|
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>
|
<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 { FormSkeleton } from '@/components/Skeleton'
|
||||||
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
||||||
import { useConfirm } from '@/lib/useConfirm'
|
import { useConfirm } from '@/lib/useConfirm'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useStores, useCurrencies } from '@/lib/useLookups'
|
import { useStores, useCurrencies } from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import {
|
import {
|
||||||
|
|
@ -211,6 +212,13 @@ export function DemandEditPage() {
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 }
|
: { 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 />
|
if (!isNew && existing.isLoading) return <FormSkeleton />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { Plus, Truck } from 'lucide-react'
|
import { Plus, Truck } from 'lucide-react'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
|
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { type DemandListRow, DemandStatus, demandPaymentLabel } from '@/lib/types'
|
import { type DemandListRow, DemandStatus, demandPaymentLabel } from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -20,6 +22,13 @@ export function DemandsPage() {
|
||||||
const moneyFmt = fractional
|
const moneyFmt = fractional
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 }
|
: { maximumFractionDigits: 0 }
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новую отгрузку.
|
||||||
|
useShortcuts({
|
||||||
|
'/': () => searchRef.current?.focus(),
|
||||||
|
'n': () => navigate('/sales/demands/new'),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
|
|
@ -27,7 +36,7 @@ export function DemandsPage() {
|
||||||
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Отгрузка товара юрлицу-контрагенту.'}
|
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Отгрузка товара юрлицу-контрагенту.'}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar value={search} onChange={setSearch} placeholder="По номеру или контрагенту…" />
|
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру или контрагенту…" />
|
||||||
<Link to="/sales/demands/new">
|
<Link to="/sales/demands/new">
|
||||||
<Button><Plus className="w-4 h-4" /> Новая отгрузка</Button>
|
<Button><Plus className="w-4 h-4" /> Новая отгрузка</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||||
import { FormSkeleton } from '@/components/Skeleton'
|
import { FormSkeleton } from '@/components/Skeleton'
|
||||||
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
||||||
import { useConfirm } from '@/lib/useConfirm'
|
import { useConfirm } from '@/lib/useConfirm'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useStores, useCurrencies } from '@/lib/useLookups'
|
import { useStores, useCurrencies } from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { EnterStatus, type EnterDto, type Product } from '@/lib/types'
|
import { EnterStatus, type EnterDto, type Product } from '@/lib/types'
|
||||||
|
|
@ -191,6 +192,13 @@ export function EnterEditPage() {
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 }
|
: { 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 />
|
if (!isNew && existing.isLoading) return <FormSkeleton />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { Plus, PackagePlus } from 'lucide-react'
|
import { Plus, PackagePlus } from 'lucide-react'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
|
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { type EnterListRow, EnterStatus } from '@/lib/types'
|
import { type EnterListRow, EnterStatus } from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -20,6 +22,13 @@ export function EntersPage() {
|
||||||
const moneyFmt = fractional
|
const moneyFmt = fractional
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 }
|
: { maximumFractionDigits: 0 }
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новое оприходование.
|
||||||
|
useShortcuts({
|
||||||
|
'/': () => searchRef.current?.focus(),
|
||||||
|
'n': () => navigate('/inventory/enters/new'),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
|
|
@ -27,7 +36,7 @@ export function EntersPage() {
|
||||||
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Постановка товара на склад без поставщика — начальные остатки и излишки.'}
|
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Постановка товара на склад без поставщика — начальные остатки и излишки.'}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar value={search} onChange={setSearch} placeholder="По номеру…" />
|
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
|
||||||
<Link to="/inventory/enters/new">
|
<Link to="/inventory/enters/new">
|
||||||
<Button><Plus className="w-4 h-4" /> Новое оприходование</Button>
|
<Button><Plus className="w-4 h-4" /> Новое оприходование</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { Plus, ClipboardList } from 'lucide-react'
|
import { Plus, ClipboardList } from 'lucide-react'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
|
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { type InventoryListRow, InventoryStatus } from '@/lib/types'
|
import { type InventoryListRow, InventoryStatus } from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -20,6 +22,13 @@ export function InventoriesPage() {
|
||||||
const moneyFmt = fractional
|
const moneyFmt = fractional
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 }
|
: { maximumFractionDigits: 0 }
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новую инвентаризацию.
|
||||||
|
useShortcuts({
|
||||||
|
'/': () => searchRef.current?.focus(),
|
||||||
|
'n': () => navigate('/inventory/inventories/new'),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
|
|
@ -27,7 +36,7 @@ export function InventoriesPage() {
|
||||||
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Пересчёт фактических остатков склада.'}
|
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Пересчёт фактических остатков склада.'}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar value={search} onChange={setSearch} placeholder="По номеру…" />
|
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
|
||||||
<Link to="/inventory/inventories/new">
|
<Link to="/inventory/inventories/new">
|
||||||
<Button><Plus className="w-4 h-4" /> Новая инвентаризация</Button>
|
<Button><Plus className="w-4 h-4" /> Новая инвентаризация</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||||
import { FormSkeleton } from '@/components/Skeleton'
|
import { FormSkeleton } from '@/components/Skeleton'
|
||||||
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
||||||
import { useConfirm } from '@/lib/useConfirm'
|
import { useConfirm } from '@/lib/useConfirm'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useStores } from '@/lib/useLookups'
|
import { useStores } from '@/lib/useLookups'
|
||||||
import { InventoryStatus, type InventoryDto } from '@/lib/types'
|
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
|
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 />
|
if (!isNew && existing.isLoading) return <FormSkeleton />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||||
import { FormSkeleton } from '@/components/Skeleton'
|
import { FormSkeleton } from '@/components/Skeleton'
|
||||||
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
||||||
import { useConfirm } from '@/lib/useConfirm'
|
import { useConfirm } from '@/lib/useConfirm'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useStores, useCurrencies } from '@/lib/useLookups'
|
import { useStores, useCurrencies } from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { LossStatus, LossReason, lossReasonLabel, type LossDto, type Product } from '@/lib/types'
|
import { LossStatus, LossReason, lossReasonLabel, type LossDto, type Product } from '@/lib/types'
|
||||||
|
|
@ -194,6 +195,13 @@ export function LossEditPage() {
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 }
|
: { 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 />
|
if (!isNew && existing.isLoading) return <FormSkeleton />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2 } from 'lucide-react'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
|
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { type LossListRow, LossStatus, lossReasonLabel } from '@/lib/types'
|
import { type LossListRow, LossStatus, lossReasonLabel } from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -20,6 +22,13 @@ export function LossesPage() {
|
||||||
const moneyFmt = fractional
|
const moneyFmt = fractional
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 }
|
: { maximumFractionDigits: 0 }
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новое списание.
|
||||||
|
useShortcuts({
|
||||||
|
'/': () => searchRef.current?.focus(),
|
||||||
|
'n': () => navigate('/inventory/losses/new'),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
|
|
@ -27,7 +36,7 @@ export function LossesPage() {
|
||||||
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Брак, просрочка, повреждения, недостача.'}
|
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Брак, просрочка, повреждения, недостача.'}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar value={search} onChange={setSearch} placeholder="По номеру…" />
|
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
|
||||||
<Link to="/inventory/losses/new">
|
<Link to="/inventory/losses/new">
|
||||||
<Button><Plus className="w-4 h-4" /> Новое списание</Button>
|
<Button><Plus className="w-4 h-4" /> Новое списание</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||||
import { FormSkeleton } from '@/components/Skeleton'
|
import { FormSkeleton } from '@/components/Skeleton'
|
||||||
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
||||||
import { useConfirm } from '@/lib/useConfirm'
|
import { useConfirm } from '@/lib/useConfirm'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { generateEan13InternalPrefix2, generateBarcode, generateArticle } from '@/lib/barcode'
|
import { generateEan13InternalPrefix2, generateBarcode, generateArticle } from '@/lib/barcode'
|
||||||
|
|
||||||
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
|
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
|
||||||
|
|
@ -222,6 +223,15 @@ export function ProductEditPage() {
|
||||||
&& form.barcodes.length > 0
|
&& form.barcodes.length > 0
|
||||||
&& missingRequiredPrices.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 />
|
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 { Link, useNavigate } from 'react-router-dom'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { EmptyState } from '@/components/EmptyState'
|
import { EmptyState } from '@/components/EmptyState'
|
||||||
|
|
@ -7,6 +7,7 @@ import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Plus, Filter, X, FolderTree, Package } from 'lucide-react'
|
import { Plus, Filter, X, FolderTree, Package } from 'lucide-react'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { usePriceTypes } from '@/lib/useLookups'
|
import { usePriceTypes } from '@/lib/useLookups'
|
||||||
import { ProductGroupTree } from '@/components/ProductGroupTree'
|
import { ProductGroupTree } from '@/components/ProductGroupTree'
|
||||||
|
|
@ -108,6 +109,13 @@ export function ProductsPage() {
|
||||||
const showMarked = org.data?.showMarkedOnProduct ?? false
|
const showMarked = org.data?.showMarkedOnProduct ?? false
|
||||||
const activeCount = activeFilterCount(filters)
|
const activeCount = activeFilterCount(filters)
|
||||||
const [groupsOpen, setGroupsOpen] = useState(false)
|
const [groupsOpen, setGroupsOpen] = useState(false)
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новый товар.
|
||||||
|
useShortcuts({
|
||||||
|
'/': () => searchRef.current?.focus(),
|
||||||
|
'n': () => navigate('/catalog/products/new'),
|
||||||
|
})
|
||||||
|
|
||||||
type Col = {
|
type Col = {
|
||||||
header: string
|
header: string
|
||||||
|
|
@ -213,7 +221,7 @@ export function ProductsPage() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-auto flex flex-wrap items-center gap-2">
|
<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
|
<Button
|
||||||
variant={filtersOpen || activeCount ? 'primary' : 'secondary'}
|
variant={filtersOpen || activeCount ? 'primary' : 'secondary'}
|
||||||
onClick={() => setFiltersOpen((v) => !v)}
|
onClick={() => setFiltersOpen((v) => !v)}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||||
import { FormSkeleton } from '@/components/Skeleton'
|
import { FormSkeleton } from '@/components/Skeleton'
|
||||||
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
||||||
import { useConfirm } from '@/lib/useConfirm'
|
import { useConfirm } from '@/lib/useConfirm'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useStores, useCurrencies } from '@/lib/useLookups'
|
import { useStores, useCurrencies } from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { RetailSaleStatus, PaymentMethod, type RetailSaleDto, type Product } from '@/lib/types'
|
import { RetailSaleStatus, PaymentMethod, type RetailSaleDto, type Product } from '@/lib/types'
|
||||||
|
|
@ -210,6 +211,13 @@ export function RetailSaleEditPage() {
|
||||||
// кнопка disabled с подсказкой.
|
// кнопка disabled с подсказкой.
|
||||||
const canSave = !!form.storeId && !!form.currencyId && isDraft && form.lines.length > 0
|
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 />
|
if (!isNew && existing.isLoading) return <FormSkeleton />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { Plus, Receipt } from 'lucide-react'
|
import { Plus, Receipt } from 'lucide-react'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
|
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { type RetailSaleListRow, RetailSaleStatus, PaymentMethod } from '@/lib/types'
|
import { type RetailSaleListRow, RetailSaleStatus, PaymentMethod } from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -28,6 +30,13 @@ export function RetailSalesPage() {
|
||||||
const moneyFmt = fractional
|
const moneyFmt = fractional
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 }
|
: { maximumFractionDigits: 0 }
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новый чек.
|
||||||
|
useShortcuts({
|
||||||
|
'/': () => searchRef.current?.focus(),
|
||||||
|
'n': () => navigate('/sales/retail/new'),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
|
|
@ -35,7 +44,7 @@ export function RetailSalesPage() {
|
||||||
description={data ? `${data.total.toLocaleString('ru')} чеков` : 'Чеки розничных продаж'}
|
description={data ? `${data.total.toLocaleString('ru')} чеков` : 'Чеки розничных продаж'}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar value={search} onChange={setSearch} placeholder="По номеру чека…" />
|
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру чека…" />
|
||||||
<Link to="/sales/retail/new">
|
<Link to="/sales/retail/new">
|
||||||
<Button><Plus className="w-4 h-4" /> Новый чек</Button>
|
<Button><Plus className="w-4 h-4" /> Новый чек</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||||
import { FormSkeleton } from '@/components/Skeleton'
|
import { FormSkeleton } from '@/components/Skeleton'
|
||||||
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
||||||
import { useConfirm } from '@/lib/useConfirm'
|
import { useConfirm } from '@/lib/useConfirm'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useStores, useCurrencies } from '@/lib/useLookups'
|
import { useStores, useCurrencies } from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { SupplierReturnStatus, type SupplierReturnDto, type Product } from '@/lib/types'
|
import { SupplierReturnStatus, type SupplierReturnDto, type Product } from '@/lib/types'
|
||||||
|
|
@ -197,6 +198,13 @@ export function SupplierReturnEditPage() {
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 }
|
: { 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 />
|
if (!isNew && existing.isLoading) return <FormSkeleton />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { Plus, Undo2 } from 'lucide-react'
|
import { Plus, Undo2 } from 'lucide-react'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
|
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { type SupplierReturnListRow, SupplierReturnStatus } from '@/lib/types'
|
import { type SupplierReturnListRow, SupplierReturnStatus } from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -20,6 +22,13 @@ export function SupplierReturnsPage() {
|
||||||
const moneyFmt = fractional
|
const moneyFmt = fractional
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 }
|
: { maximumFractionDigits: 0 }
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новый возврат поставщику.
|
||||||
|
useShortcuts({
|
||||||
|
'/': () => searchRef.current?.focus(),
|
||||||
|
'n': () => navigate('/purchases/supplier-returns/new'),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
|
|
@ -27,7 +36,7 @@ export function SupplierReturnsPage() {
|
||||||
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Возврат поставщику (брак, излишек, неликвид).'}
|
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Возврат поставщику (брак, излишек, неликвид).'}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
|
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
|
||||||
<Link to="/purchases/supplier-returns/new">
|
<Link to="/purchases/supplier-returns/new">
|
||||||
<Button><Plus className="w-4 h-4" /> Новый возврат</Button>
|
<Button><Plus className="w-4 h-4" /> Новый возврат</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { Plus, PackagePlus } from 'lucide-react'
|
import { Plus, PackagePlus } from 'lucide-react'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
|
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { type SupplyListRow, SupplyStatus } from '@/lib/types'
|
import { type SupplyListRow, SupplyStatus } from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -20,6 +22,13 @@ export function SuppliesPage() {
|
||||||
const moneyFmt = fractional
|
const moneyFmt = fractional
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 }
|
: { maximumFractionDigits: 0 }
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новую приёмку.
|
||||||
|
useShortcuts({
|
||||||
|
'/': () => searchRef.current?.focus(),
|
||||||
|
'n': () => navigate('/purchases/supplies/new'),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
|
|
@ -27,7 +36,7 @@ export function SuppliesPage() {
|
||||||
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Документы поступления товара от поставщиков'}
|
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Документы поступления товара от поставщиков'}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
|
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру или поставщику…" />
|
||||||
<Link to="/purchases/supplies/new">
|
<Link to="/purchases/supplies/new">
|
||||||
<Button><Plus className="w-4 h-4" /> Новая приёмка</Button>
|
<Button><Plus className="w-4 h-4" /> Новая приёмка</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||||
import { FormSkeleton } from '@/components/Skeleton'
|
import { FormSkeleton } from '@/components/Skeleton'
|
||||||
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
||||||
import { useConfirm } from '@/lib/useConfirm'
|
import { useConfirm } from '@/lib/useConfirm'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups'
|
import { useStores, useCurrencies, usePriceTypes } from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
|
import { SupplyStatus, type SupplyDto, type Product } from '@/lib/types'
|
||||||
|
|
@ -270,6 +271,14 @@ export function SupplyEditPage() {
|
||||||
&& form.lines.length > 0
|
&& form.lines.length > 0
|
||||||
&& isDraft
|
&& 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 />
|
if (!isNew && existing.isLoading) return <FormSkeleton />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'
|
||||||
import { FormSkeleton } from '@/components/Skeleton'
|
import { FormSkeleton } from '@/components/Skeleton'
|
||||||
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
import { Breadcrumbs } from '@/components/Breadcrumbs'
|
||||||
import { useConfirm } from '@/lib/useConfirm'
|
import { useConfirm } from '@/lib/useConfirm'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useStores } from '@/lib/useLookups'
|
import { useStores } from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { TransferStatus, type TransferDto, type Product } from '@/lib/types'
|
import { TransferStatus, type TransferDto, type Product } from '@/lib/types'
|
||||||
|
|
@ -179,6 +180,13 @@ export function TransferEditPage() {
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 }
|
: { 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 />
|
if (!isNew && existing.isLoading) return <FormSkeleton />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
import { Plus, ArrowRight, ArrowLeftRight } from 'lucide-react'
|
import { Plus, ArrowRight, ArrowLeftRight } from 'lucide-react'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
|
|
@ -7,6 +8,7 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { useCatalogList } from '@/lib/useCatalog'
|
import { useCatalogList } from '@/lib/useCatalog'
|
||||||
|
import { useShortcuts } from '@/lib/useShortcuts'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { type TransferListRow, TransferStatus } from '@/lib/types'
|
import { type TransferListRow, TransferStatus } from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -20,6 +22,13 @@ export function TransfersPage() {
|
||||||
const moneyFmt = fractional
|
const moneyFmt = fractional
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 }
|
: { maximumFractionDigits: 0 }
|
||||||
|
const searchRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Хоткеи list-страницы: `/` фокусит поиск, `n` — создать новое перемещение.
|
||||||
|
useShortcuts({
|
||||||
|
'/': () => searchRef.current?.focus(),
|
||||||
|
'n': () => navigate('/inventory/transfers/new'),
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ListPageShell
|
<ListPageShell
|
||||||
|
|
@ -27,7 +36,7 @@ export function TransfersPage() {
|
||||||
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Перевод товара между складами.'}
|
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Перевод товара между складами.'}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
<SearchBar value={search} onChange={setSearch} placeholder="По номеру…" />
|
<SearchBar ref={searchRef} value={search} onChange={setSearch} placeholder="По номеру…" />
|
||||||
<Link to="/inventory/transfers/new">
|
<Link to="/inventory/transfers/new">
|
||||||
<Button><Plus className="w-4 h-4" /> Новое перемещение</Button>
|
<Button><Plus className="w-4 h-4" /> Новое перемещение</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue