diff --git a/src/food-market.api/Controllers/Search/GlobalSearchController.cs b/src/food-market.api/Controllers/Search/GlobalSearchController.cs new file mode 100644 index 0000000..8b3cd4b --- /dev/null +++ b/src/food-market.api/Controllers/Search/GlobalSearchController.cs @@ -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; + +/// Глобальный поиск для Cmd+K-палитры. Tenant-scoped. +/// +/// Ищет по: Products (name/article/barcode), Counterparties (name/bin), +/// Supplies/RetailSales/Demands (number). Возвращает 4 сгруппированные пачки +/// по ≤ элементов. UI рендерит как разделы. +/// +/// Эндпоинт не делает «полный» поиск по полным таблицам — задача быстро +/// показать топ-N релевантных. Для точной аналитики есть отдельные list-API. +[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 Products, + IReadOnlyList Counterparties, + IReadOnlyList Documents); + + [HttpGet("global")] + public async Task> 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(), + Array.Empty(), + Array.Empty())); + } + 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)); + } +} diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index 1502393..ea6d1ec 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -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' и '?', не блокирует ввод в инпутах. */} + {/* Командная палитра (Cmd+K / Ctrl+K) — глобальный поиск + навигация. */} + setPaletteOpen(false)} /> ) } diff --git a/src/food-market.web/src/components/CommandPalette.tsx b/src/food-market.web/src/components/CommandPalette.tsx new file mode 100644 index 0000000..50f4fbc --- /dev/null +++ b/src/food-market.web/src/components/CommandPalette.tsx @@ -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 делится на части, совпавшие оборачиваем в . + */ +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 & { 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) + ? {p} + : {p} + ) +} + +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(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(`/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() + 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 ( +
{ if (e.target === e.currentTarget) onClose() }} + > +
+
+ + 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" + /> + Esc +
+ +
+ {items.length === 0 ? ( +
+ {debounced.length >= 2 && !search.isLoading + ? t('cmdk.empty', { defaultValue: 'Ничего не найдено' }) + : t('cmdk.hint', { defaultValue: 'Начните вводить, чтобы найти товары, контрагентов, документы или страницы' })} +
+ ) : ( + groups.map((g) => ( +
+
+ {g.title} +
+
    + {g.items.map((it) => { + const idx = items.indexOf(it) + const isActive = idx === active + const Icon = it.icon + return ( +
  • 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' + }`} + > + + + {highlight(it.label, debounced)} + + {it.hint && {it.hint}} + {isActive && } +
  • + ) + })} +
+
+ )) + )} +
+ +
+ + ↑↓ + навигация + Enter + выбрать + + {search.isLoading && Поиск…} +
+
+
+ ) +}