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 { 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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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