feat(s10-3): глобальная Cmd+K палитра + GET /api/search/global
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Docker API / Build + push API (push) Has been cancelled
Docker API / Deploy API on stage (push) Has been cancelled

S10-3: командная палитра для быстрой навигации и поиска.

Backend GlobalSearchController:
- GET /api/search/global?q=… ищет в 3 источниках: товары (name/article/
  barcode startsWith), контрагенты (name/bin contains), документы
  (Supply.Number, RetailSale.Number, Demand.Number) — по ≤5 в каждой
  группе. Tenant-scoped, требует ≥2 символа в q.
- Lower-cased contains; EF8 OrderBy на record-projection ломается,
  поэтому проектируем в anonymous, потом маппим в DocumentHit.

Frontend CommandPalette.tsx:
- Глобальный хоткей Cmd+K / Ctrl+K (listener на document в AppLayout).
- Статический список 20 страниц для навигации без меню (даже без API).
- Дебаунс query 200мс → GET /api/search/global при q ≥ 2 символов.
- Recent items: localStorage 'fm.cmdk.recent', последние 10 выбранных
  показываются когда q пустой.
- Подсветка совпадений через RegExp split + <mark>.
- Хоткеи: ↑↓ Enter Esc; группированный список (Recent / Pages /
  Товары / Контрагенты / Документы).

Проверено на стэйдже: q='колбас' → 3 продукта, q='Алматы' →
2 контрагента (поставщики), q='ПР-Y1-00019' → 5 retail-sale.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-06-06 01:20:05 +05:00
parent 1044818fbb
commit f9fa028fe5
3 changed files with 462 additions and 2 deletions

View file

@ -0,0 +1,105 @@
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Search;
/// <summary>Глобальный поиск для Cmd+K-палитры. Tenant-scoped.
///
/// Ищет по: Products (name/article/barcode), Counterparties (name/bin),
/// Supplies/RetailSales/Demands (number). Возвращает 4 сгруппированные пачки
/// по ≤<see cref="DefaultPerGroup"/> элементов. UI рендерит как разделы.
///
/// Эндпоинт не делает «полный» поиск по полным таблицам — задача быстро
/// показать топ-N релевантных. Для точной аналитики есть отдельные list-API.</summary>
[ApiController]
[Authorize]
[Route("api/search")]
public class GlobalSearchController : ControllerBase
{
private const int DefaultPerGroup = 5;
private const int MaxPerGroup = 15;
private readonly AppDbContext _db;
public GlobalSearchController(AppDbContext db) => _db = db;
public record ProductHit(Guid Id, string Name, string? Article, string? Barcode);
public record CounterpartyHit(Guid Id, string Name, string? Bin, int Type);
public record DocumentHit(string Kind, Guid Id, string Number, DateTime Date, decimal Total);
public record GlobalSearchResponse(
IReadOnlyList<ProductHit> Products,
IReadOnlyList<CounterpartyHit> Counterparties,
IReadOnlyList<DocumentHit> Documents);
[HttpGet("global")]
public async Task<ActionResult<GlobalSearchResponse>> Search(
[FromQuery] string? q,
[FromQuery] int perGroup = DefaultPerGroup,
CancellationToken ct = default)
{
var query = (q ?? "").Trim();
if (query.Length < 2)
{
return Ok(new GlobalSearchResponse(
Array.Empty<ProductHit>(),
Array.Empty<CounterpartyHit>(),
Array.Empty<DocumentHit>()));
}
perGroup = Math.Clamp(perGroup, 1, MaxPerGroup);
var qLower = query.ToLower();
// Products: name OR article OR primary barcode startsWith.
var products = await (
from p in _db.Products.AsNoTracking()
where p.Name.ToLower().Contains(qLower)
|| (p.Article != null && p.Article.ToLower().Contains(qLower))
|| p.Barcodes.Any(b => b.Code.StartsWith(query))
select new ProductHit(
p.Id, p.Name, p.Article,
p.Barcodes.Where(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault()))
.Take(perGroup).ToListAsync(ct);
// Counterparties: name OR bin.
var cps = await (
from c in _db.Counterparties.AsNoTracking()
where c.Name.ToLower().Contains(qLower)
|| (c.Bin != null && c.Bin.Contains(query))
select new CounterpartyHit(c.Id, c.Name, c.Bin, (int)c.Type))
.Take(perGroup).ToListAsync(ct);
// Документы — ищем по номеру в трёх типах документов. Объединяем
// три отдельных запроса (UNION ALL в SQL EF не любит без явного
// костыля) и сортируем по дате.
// Сначала OrderBy/Take по primitive (Date), потом маппим в record:
// EF8 не транслирует ORDER BY на record-projection.
var supplies = (await _db.Supplies.AsNoTracking()
.Where(s => s.Number.ToLower().Contains(qLower))
.OrderByDescending(s => s.Date)
.Select(s => new { s.Id, s.Number, s.Date, s.Total })
.Take(perGroup).ToListAsync(ct))
.Select(s => new DocumentHit("supply", s.Id, s.Number, s.Date, s.Total)).ToList();
var sales = (await _db.RetailSales.AsNoTracking()
.Where(s => s.Number.ToLower().Contains(qLower) && s.Status == RetailSaleStatus.Posted)
.OrderByDescending(s => s.Date)
.Select(s => new { s.Id, s.Number, s.Date, s.Total })
.Take(perGroup).ToListAsync(ct))
.Select(s => new DocumentHit("retail-sale", s.Id, s.Number, s.Date, s.Total)).ToList();
var demands = (await _db.Demands.AsNoTracking()
.Where(d => d.Number.ToLower().Contains(qLower))
.OrderByDescending(d => d.Date)
.Select(d => new { d.Id, d.Number, d.Date, d.Total })
.Take(perGroup).ToListAsync(ct))
.Select(d => new DocumentHit("demand", d.Id, d.Number, d.Date, d.Total)).ToList();
var documents = supplies.Concat(sales).Concat(demands)
.OrderByDescending(d => d.Date)
.Take(perGroup).ToList();
return Ok(new GlobalSearchResponse(products, cps, documents));
}
}

View file

@ -14,6 +14,7 @@ import { Logo } from './Logo'
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
import { ShortcutsOverlay } from './ShortcutsOverlay'
import { LanguageSwitcher } from './LanguageSwitcher'
import { CommandPalette } from './CommandPalette'
interface MeResponse {
sub: string
@ -183,9 +184,23 @@ export function AppLayout() {
}, [isSuperAdmin, location2.pathname])
const [drawerOpen, setDrawerOpen] = useState(false)
const [paletteOpen, setPaletteOpen] = useState(false)
const location = useLocation()
// Закрывать drawer при смене маршрута.
useEffect(() => { setDrawerOpen(false) }, [location.pathname])
// Закрывать drawer + палитру при смене маршрута.
useEffect(() => { setDrawerOpen(false); setPaletteOpen(false) }, [location.pathname])
// Глобальный хоткей Cmd+K / Ctrl+K — открывает командную палитру.
// Не используем useShortcuts (там mod+s обрабатывается на странице);
// ставим listener на document верхним уровнем, capture-фазу не нужно.
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setPaletteOpen((x) => !x)
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [])
const sidebar = (
<>
@ -284,6 +299,8 @@ export function AppLayout() {
{/* Глобальный «?»-оверлей со списком горячих клавиш. Сам обрабатывает
'Esc' и '?', не блокирует ввод в инпутах. */}
<ShortcutsOverlay />
{/* Командная палитра (Cmd+K / Ctrl+K) — глобальный поиск + навигация. */}
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
</div>
)
}

View file

@ -0,0 +1,338 @@
/**
* Глобальная команд-палитра (Cmd+K / Ctrl+K).
*
* Что ищется:
* 1. Страницы (статический список) для быстрой навигации без меню.
* 2. Товары / контрагенты / документы через GET /api/search/global.
*
* Recent items: последние 10 выбранных пунктов хранятся в localStorage
* (`fm.cmdk.recent`) и показываются когда запрос пустой.
*
* Хоткеи внутри палитры: / навигация, Enter выбрать, Esc закрыть.
*
* Подсветка совпадений простая текстовая: query превращается в RegExp
* (escape'нутый), label делится на части, совпавшие оборачиваем в <mark>.
*/
import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
Search, Package, Users, FileText, ShoppingCart, BarChart3, Settings,
Boxes, TruckIcon, Send, Tag, Shield, History, Warehouse, ArrowRight,
} from 'lucide-react'
import { api } from '@/lib/api'
interface GlobalSearchResponse {
products: { id: string; name: string; article: string | null; barcode: string | null }[]
counterparties: { id: string; name: string; bin: string | null; type: number }[]
documents: { kind: string; id: string; number: string; date: string; total: number }[]
}
interface PaletteItem {
id: string
label: string
hint?: string
icon: React.ComponentType<{ className?: string }>
group: 'pages' | 'products' | 'counterparties' | 'documents' | 'recent'
action: () => void
}
const STATIC_PAGES: Array<Omit<PaletteItem, 'action'> & { path: string }> = [
{ id: 'page:dashboard', label: 'Главная', icon: BarChart3, group: 'pages', path: '/' },
{ id: 'page:products', label: 'Каталог · Товары', icon: Package, group: 'pages', path: '/catalog/products' },
{ id: 'page:counterparties', label: 'Каталог · Контрагенты', icon: Users, group: 'pages', path: '/catalog/counterparties' },
{ id: 'page:stock', label: 'Остатки', icon: Boxes, group: 'pages', path: '/inventory/stock' },
{ id: 'page:movements', label: 'Движения товаров', icon: History, group: 'pages', path: '/inventory/movements' },
{ id: 'page:supplies', label: 'Закупки · Приёмки', icon: TruckIcon, group: 'pages', path: '/purchases/supplies' },
{ id: 'page:enters', label: 'Оприходования', icon: Boxes, group: 'pages', path: '/inventory/enters' },
{ id: 'page:retail-sales', label: 'Продажи · Розничные чеки', icon: ShoppingCart, group: 'pages', path: '/sales/retail' },
{ id: 'page:demands', label: 'Продажи · Оптовые отгрузки', icon: Send, group: 'pages', path: '/sales/demands' },
{ id: 'page:reports-sales', label: 'Отчёты · Продажи', icon: BarChart3, group: 'pages', path: '/reports/sales' },
{ id: 'page:reports-stock', label: 'Отчёты · Остатки', icon: Boxes, group: 'pages', path: '/reports/stock' },
{ id: 'page:reports-profit', label: 'Отчёты · Прибыль', icon: BarChart3, group: 'pages', path: '/reports/profit' },
{ id: 'page:reports-abc', label: 'Отчёты · ABC-анализ', icon: BarChart3, group: 'pages', path: '/reports/abc' },
{ id: 'page:promotions', label: 'Акции и промокоды', icon: Tag, group: 'pages', path: '/promotions' },
{ id: 'page:loyalty-programs', label: 'Программы лояльности', icon: Shield, group: 'pages', path: '/loyalty/programs' },
{ id: 'page:loyalty-cards', label: 'Карты лояльности', icon: Shield, group: 'pages', path: '/loyalty/cards' },
{ id: 'page:settings', label: 'Настройки организации', icon: Settings, group: 'pages', path: '/settings/organization' },
{ id: 'page:stores', label: 'Настройки · Склады', icon: Warehouse, group: 'pages', path: '/catalog/stores' },
{ id: 'page:employees', label: 'Настройки · Сотрудники', icon: Users, group: 'pages', path: '/settings/employees' },
{ id: 'page:audit-log', label: 'Журнал изменений', icon: FileText, group: 'pages', path: '/audit-log' },
]
const RECENT_KEY = 'fm.cmdk.recent'
const RECENT_LIMIT = 10
function loadRecent(): string[] {
try {
const raw = localStorage.getItem(RECENT_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed.slice(0, RECENT_LIMIT) : []
} catch { return [] }
}
function pushRecent(id: string) {
try {
const prev = loadRecent().filter(x => x !== id)
const next = [id, ...prev].slice(0, RECENT_LIMIT)
localStorage.setItem(RECENT_KEY, JSON.stringify(next))
} catch { /* localStorage недоступен — пропустим */ }
}
function highlight(text: string, query: string): React.ReactNode {
if (!query) return text
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const re = new RegExp(`(${escaped})`, 'ig')
const parts = text.split(re)
return parts.map((p, i) =>
re.test(p)
? <mark key={i} className="bg-amber-200 dark:bg-amber-500/40 text-slate-900 dark:text-slate-100 rounded px-0.5">{p}</mark>
: <span key={i}>{p}</span>
)
}
function docKindToPath(kind: string, id: string): string {
switch (kind) {
case 'supply': return `/purchases/supplies/${id}`
case 'retail-sale': return `/sales/retail/${id}`
case 'demand': return `/sales/demands/${id}`
default: return '/'
}
}
function docKindLabel(kind: string): string {
switch (kind) {
case 'supply': return 'Приёмка'
case 'retail-sale': return 'Розничный чек'
case 'demand': return 'Оптовая отгрузка'
default: return kind
}
}
interface PaletteProps {
open: boolean
onClose: () => void
}
export function CommandPalette({ open, onClose }: PaletteProps) {
const navigate = useNavigate()
const { t } = useTranslation()
const [query, setQuery] = useState('')
const [debounced, setDebounced] = useState('')
const [active, setActive] = useState(0)
const [, startTransition] = useTransition()
const inputRef = useRef<HTMLInputElement>(null)
// Debounce query → debounced (для API-запроса). 200мс.
useEffect(() => {
const t = setTimeout(() => setDebounced(query.trim()), 200)
return () => clearTimeout(t)
}, [query])
// Reset когда модалка открывается, фокус на input.
useEffect(() => {
if (open) {
setQuery('')
setActive(0)
setTimeout(() => inputRef.current?.focus(), 0)
}
}, [open])
const search = useQuery({
queryKey: ['/api/search/global', debounced],
queryFn: async () => (await api.get<GlobalSearchResponse>(`/api/search/global?q=${encodeURIComponent(debounced)}`)).data,
enabled: open && debounced.length >= 2,
})
const recentIds = useMemo(() => loadRecent(), [open])
const items: PaletteItem[] = useMemo(() => {
const q = query.trim().toLowerCase()
const all: PaletteItem[] = []
// Recent (только когда запрос пуст)
if (q.length === 0) {
const byId = new Map<string, PaletteItem>()
for (const p of STATIC_PAGES) {
byId.set(p.id, {
...p,
group: 'recent',
action: () => { pushRecent(p.id); navigate(p.path); onClose() },
})
}
for (const id of recentIds) {
const it = byId.get(id)
if (it) all.push(it)
}
}
// Pages (filter по q)
for (const p of STATIC_PAGES) {
if (q.length > 0 && !p.label.toLowerCase().includes(q)) continue
all.push({
...p,
action: () => { pushRecent(p.id); navigate(p.path); onClose() },
})
}
// Products
for (const p of search.data?.products ?? []) {
all.push({
id: `product:${p.id}`,
label: p.name,
hint: p.article ? `арт. ${p.article}` : (p.barcode ? `шк ${p.barcode}` : undefined),
icon: Package, group: 'products',
action: () => {
pushRecent(`product:${p.id}`)
navigate(`/catalog/products/${p.id}`); onClose()
},
})
}
// Counterparties
for (const c of search.data?.counterparties ?? []) {
all.push({
id: `cp:${c.id}`,
label: c.name,
hint: c.bin ?? undefined,
icon: Users, group: 'counterparties',
action: () => {
pushRecent(`cp:${c.id}`)
navigate(`/catalog/counterparties?focus=${c.id}`); onClose()
},
})
}
// Documents
for (const d of search.data?.documents ?? []) {
const dt = new Date(d.date).toLocaleDateString('ru')
all.push({
id: `${d.kind}:${d.id}`,
label: `${d.number}`,
hint: `${docKindLabel(d.kind)} · ${dt}`,
icon: FileText, group: 'documents',
action: () => {
pushRecent(`${d.kind}:${d.id}`)
navigate(docKindToPath(d.kind, d.id)); onClose()
},
})
}
return all
}, [query, debounced, search.data, recentIds, navigate, onClose])
// Reset active когда меняется список.
useEffect(() => { setActive(0) }, [items.length])
// Хоткеи: ↑/↓/Enter/Esc.
useEffect(() => {
if (!open) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.preventDefault(); onClose(); return }
if (e.key === 'ArrowDown') {
e.preventDefault()
startTransition(() => setActive(a => Math.min(items.length - 1, a + 1)))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
startTransition(() => setActive(a => Math.max(0, a - 1)))
} else if (e.key === 'Enter') {
e.preventDefault()
items[active]?.action()
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, items, active, onClose])
if (!open) return null
// Группируем для рендера
const groups: Array<{ group: PaletteItem['group']; title: string; items: PaletteItem[] }> = ([
{ group: 'recent' as const, title: t('cmdk.recent', { defaultValue: 'Недавнее' }), items: items.filter(i => i.group === 'recent') },
{ group: 'pages' as const, title: t('cmdk.pages', { defaultValue: 'Страницы' }), items: items.filter(i => i.group === 'pages') },
{ group: 'products' as const, title: t('cmdk.products', { defaultValue: 'Товары' }), items: items.filter(i => i.group === 'products') },
{ group: 'counterparties' as const, title: t('cmdk.counterparties', { defaultValue: 'Контрагенты' }), items: items.filter(i => i.group === 'counterparties') },
{ group: 'documents' as const, title: t('cmdk.documents', { defaultValue: 'Документы' }), items: items.filter(i => i.group === 'documents') },
]).filter(g => g.items.length > 0)
// Чтобы подсветить активный — общий индекс через items.
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[10vh] bg-black/40 backdrop-blur-sm"
role="dialog" aria-modal="true" aria-label="Командная палитра"
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div className="w-full max-w-xl bg-white dark:bg-slate-900 rounded-xl shadow-2xl border border-slate-200 dark:border-slate-700 overflow-hidden">
<div className="flex items-center gap-2 px-4 py-3 border-b border-slate-200 dark:border-slate-800">
<Search className="w-4 h-4 text-slate-400 flex-shrink-0" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('cmdk.placeholder', { defaultValue: 'Поиск товаров, контрагентов, документов или страниц…' })}
className="flex-1 bg-transparent outline-none text-sm text-slate-900 dark:text-slate-100 placeholder:text-slate-400"
/>
<kbd className="text-[10px] px-1.5 py-0.5 rounded bg-slate-100 dark:bg-slate-800 text-slate-500">Esc</kbd>
</div>
<div className="max-h-[60vh] overflow-y-auto" role="listbox">
{items.length === 0 ? (
<div className="p-8 text-center text-sm text-slate-400">
{debounced.length >= 2 && !search.isLoading
? t('cmdk.empty', { defaultValue: 'Ничего не найдено' })
: t('cmdk.hint', { defaultValue: 'Начните вводить, чтобы найти товары, контрагентов, документы или страницы' })}
</div>
) : (
groups.map((g) => (
<div key={g.group}>
<div className="px-4 pt-3 pb-1 text-[10px] uppercase tracking-wider text-slate-400">
{g.title}
</div>
<ul>
{g.items.map((it) => {
const idx = items.indexOf(it)
const isActive = idx === active
const Icon = it.icon
return (
<li
key={it.id}
role="option"
aria-selected={isActive}
onMouseEnter={() => setActive(idx)}
onClick={it.action}
className={`px-4 py-2 flex items-center gap-3 cursor-pointer text-sm ${
isActive
? 'bg-emerald-50 dark:bg-emerald-900/30 text-slate-900 dark:text-slate-100'
: 'text-slate-700 dark:text-slate-300 hover:bg-slate-50 dark:hover:bg-slate-800/50'
}`}
>
<Icon className="w-4 h-4 text-slate-400 flex-shrink-0" />
<span className="flex-1 truncate">
{highlight(it.label, debounced)}
</span>
{it.hint && <span className="text-xs text-slate-400 truncate max-w-[180px]">{it.hint}</span>}
{isActive && <ArrowRight className="w-3 h-3 text-emerald-600" />}
</li>
)
})}
</ul>
</div>
))
)}
</div>
<div className="px-4 py-2 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between text-[10px] text-slate-400">
<span>
<kbd className="px-1 py-0.5 bg-slate-100 dark:bg-slate-800 rounded mr-1"></kbd>
навигация
<kbd className="px-1 py-0.5 bg-slate-100 dark:bg-slate-800 rounded mx-1 ml-2">Enter</kbd>
выбрать
</span>
{search.isLoading && <span>Поиск</span>}
</div>
</div>
</div>
)
}