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
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:
parent
1044818fbb
commit
f9fa028fe5
105
src/food-market.api/Controllers/Search/GlobalSearchController.cs
Normal file
105
src/food-market.api/Controllers/Search/GlobalSearchController.cs
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,7 @@ import { Logo } from './Logo'
|
||||||
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
|
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
|
||||||
import { ShortcutsOverlay } from './ShortcutsOverlay'
|
import { ShortcutsOverlay } from './ShortcutsOverlay'
|
||||||
import { LanguageSwitcher } from './LanguageSwitcher'
|
import { LanguageSwitcher } from './LanguageSwitcher'
|
||||||
|
import { CommandPalette } from './CommandPalette'
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
sub: string
|
sub: string
|
||||||
|
|
@ -183,9 +184,23 @@ export function AppLayout() {
|
||||||
}, [isSuperAdmin, location2.pathname])
|
}, [isSuperAdmin, location2.pathname])
|
||||||
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
|
const [paletteOpen, setPaletteOpen] = useState(false)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
// Закрывать drawer при смене маршрута.
|
// Закрывать drawer + палитру при смене маршрута.
|
||||||
useEffect(() => { setDrawerOpen(false) }, [location.pathname])
|
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 = (
|
const sidebar = (
|
||||||
<>
|
<>
|
||||||
|
|
@ -284,6 +299,8 @@ export function AppLayout() {
|
||||||
{/* Глобальный «?»-оверлей со списком горячих клавиш. Сам обрабатывает
|
{/* Глобальный «?»-оверлей со списком горячих клавиш. Сам обрабатывает
|
||||||
'Esc' и '?', не блокирует ввод в инпутах. */}
|
'Esc' и '?', не блокирует ввод в инпутах. */}
|
||||||
<ShortcutsOverlay />
|
<ShortcutsOverlay />
|
||||||
|
{/* Командная палитра (Cmd+K / Ctrl+K) — глобальный поиск + навигация. */}
|
||||||
|
<CommandPalette open={paletteOpen} onClose={() => setPaletteOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
338
src/food-market.web/src/components/CommandPalette.tsx
Normal file
338
src/food-market.web/src/components/CommandPalette.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue