feat(E): Unified Search — 24 Tasks complete
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
- SPIKE-E: FTS+Vector+Permission benchmark on 10k records (all <30ms) - E-PROV: supports_fts/vector/rag/graph capability flags on all providers - E-FTS/VEC: All 11 providers refactored to BaseSearchProvider with permission filtering - E-PERM: Over-fetch strategy for vector+permission (15x faster than ANY() filter) - E-FUSE: rrf_fusion_multi() for N-way RRF over FTS+Vector+RAG+Graph - E-LLM: Query understanding cleaned up to use central llm_complete() - E-CHUNK: Document chunking module + document_chunks table with HNSW index - E-EMB: Chunk embedding ARQ jobs (index_file_chunks, reindex_chunks) - E-RAG: RAG retrieval via FileSearchProvider.search_rag() - E-GRAPH: GraphRAG BFS traversal via GraphRAGSearchProvider.search_graph() - E-IX-EVT: Auto-indexing via outbox events + delete/cleanup handlers - E-IX-RE: Batch reindex with progress tracking + reindex_all job - E-DATA-LIFE: Lifecycle module (remove/rebuild/restore/correct) + API endpoints - E-K-MEM: AgentMemorySearchProvider - E-P-AI: AIChatSearchProvider - E-P-WF: WorkflowSearchProvider - E-P-COMM: ConversationSearchProvider verified (already on BaseSearchProvider) - E-API: Filter params (date_from/to, tags, sort) + /facets endpoint - E-TOOL: unified_search AI tool registered in ToolRegistry - E-MCP: Search tool in MCP server with normal RBAC/tenant checks - E-UI-CMD: CommandPalette (Cmd+K) with debounced search + recent searches - E-UI-FAC: SearchFacets, SearchResultCard, SavedSearches components - E-TEST: 40 new tests in test_unified_search_phase_e.py (105 total green) - E-DOC: api-documentation.md, plugin-development-guide.md, test-strategy.md updated 105 tests passing, TypeScript clean.
This commit is contained in:
@@ -7,6 +7,8 @@ import { useThemeStore } from '@/store/themeStore';
|
||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { useOnlineStatus } from '@/hooks/useOnlineStatus';
|
||||
import { CommandPalette } from '@/components/search/CommandPalette';
|
||||
import { useCommandPalette } from '@/hooks/useCommandPalette';
|
||||
|
||||
function QueryClientWrapper({ children }: { children: React.ReactNode }) {
|
||||
const toast = useToast();
|
||||
@@ -79,6 +81,7 @@ export default function App() {
|
||||
<ErrorBoundary>
|
||||
<AppRouter />
|
||||
</ErrorBoundary>
|
||||
<CommandPalette />
|
||||
</QueryClientWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,16 +24,16 @@ export * from './automation';
|
||||
|
||||
// ── Search hook (uses dynamic import, kept inline) ──
|
||||
|
||||
import type { SearchResult } from './search';
|
||||
export type { SearchResult };
|
||||
import type { SearchFilters, SearchResult } from './search';
|
||||
export type { SearchResult, SearchFilters };
|
||||
|
||||
export function useGlobalSearch(query: string, entityTypes?: string[]) {
|
||||
export function useGlobalSearch(query: string, filters: SearchFilters = {}) {
|
||||
return useQuery({
|
||||
queryKey: ['globalSearch', query, entityTypes],
|
||||
queryKey: ['globalSearch', query, filters],
|
||||
queryFn: async (): Promise<SearchResult[]> => {
|
||||
if (!query.trim()) return [];
|
||||
const { search } = await import('@/api/search');
|
||||
const response = await search(query, entityTypes, 20);
|
||||
const response = await search(query, filters, 20);
|
||||
return response.results || [];
|
||||
},
|
||||
enabled: query.trim().length > 0,
|
||||
|
||||
@@ -10,13 +10,32 @@ export interface SearchResult {
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SearchFacet {
|
||||
value: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface SearchFilters {
|
||||
entityTypes?: string[];
|
||||
tags?: string[];
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
sort?: 'relevance' | 'date' | 'name';
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
results: SearchResult[];
|
||||
facets?: Record<string, Array<{ value: string; count: number }>>;
|
||||
facets?: Record<string, SearchFacet[]>;
|
||||
summary?: string;
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
export interface FacetsResponse {
|
||||
entity_types: string[];
|
||||
tags: string[];
|
||||
date_ranges: Record<string, { min: string | null; max: string | null }>;
|
||||
}
|
||||
|
||||
// Map backend entity_type to frontend route URL
|
||||
const ENTITY_URL_MAP: Record<string, (id: string, data?: Record<string, unknown>) => string> = {
|
||||
contact: (id) => `/contacts/${id}`,
|
||||
@@ -46,10 +65,14 @@ function mapBackendResult(r: Record<string, unknown>): SearchResult {
|
||||
};
|
||||
}
|
||||
|
||||
export async function search(query: string, entityTypes?: string[], limit = 20): Promise<SearchResponse> {
|
||||
export async function search(query: string, filters: SearchFilters = {}, limit = 20): Promise<SearchResponse> {
|
||||
const r = await apiClient.post('/search', {
|
||||
query,
|
||||
entity_types: entityTypes,
|
||||
entity_types: filters.entityTypes,
|
||||
tags: filters.tags,
|
||||
date_from: filters.dateFrom,
|
||||
date_to: filters.dateTo,
|
||||
sort: filters.sort || 'relevance',
|
||||
limit,
|
||||
});
|
||||
const data = r.data;
|
||||
@@ -63,6 +86,11 @@ export async function search(query: string, entityTypes?: string[], limit = 20):
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchFacets(): Promise<FacetsResponse> {
|
||||
const r = await apiClient.get('/search/facets');
|
||||
return r.data as FacetsResponse;
|
||||
}
|
||||
|
||||
export async function searchSuggest(q: string): Promise<string[]> {
|
||||
const r = await apiClient.get('/search/suggest', { params: { q } });
|
||||
return r.data.suggestions || [];
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import type { FacetsResponse } from './search';
|
||||
|
||||
export function useSearchFacets() {
|
||||
return useQuery({
|
||||
queryKey: ['searchFacets'],
|
||||
queryFn: async (): Promise<FacetsResponse> => {
|
||||
const { fetchFacets } = await import('@/api/search');
|
||||
return fetchFacets();
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { WindowContainer } from '@/components/window/WindowContainer';
|
||||
import { ErrorBoundary } from '@/components/common/ErrorBoundary';
|
||||
import { WelcomeDialog } from '@/components/onboarding/WelcomeDialog';
|
||||
import { OnboardingTour } from '@/components/onboarding/OnboardingTour';
|
||||
import { CommandPalette } from '@/components/search/CommandPalette';
|
||||
import { useOnboardingStore } from '@/store/onboardingStore';
|
||||
|
||||
export function AppShell() {
|
||||
@@ -57,6 +58,7 @@ export function AppShell() {
|
||||
{showMessageSidebar && <MessageSidebar />}
|
||||
<AIUIControlIndicator />
|
||||
<WindowContainer />
|
||||
<CommandPalette />
|
||||
<ToastContainer />
|
||||
<WelcomeDialog open={!completed && !skipped} onClose={skip} />
|
||||
<OnboardingTour />
|
||||
|
||||
@@ -8,11 +8,12 @@ import { useLogout } from '@/api/hooks';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { SearchDropdown } from '@/components/shared/SearchDropdown';
|
||||
import { SuggestionBadge } from '@/components/ai/SuggestionBadge';
|
||||
import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code } from 'lucide-react';
|
||||
import { Building, ChevronDown, Menu, Zap, Bot, Layers, Code, Search } from 'lucide-react';
|
||||
import { NotificationBell } from '@/components/layout/NotificationBell';
|
||||
import { WorkspaceSwitcher } from '@/components/layout/WorkspaceSwitcher';
|
||||
import { useWindowStore } from '@/store/windowStore';
|
||||
import { usePermission } from '@/hooks/usePermission';
|
||||
import { useCommandPaletteStore } from '@/store/commandPaletteStore';
|
||||
|
||||
export function TopBar() {
|
||||
const { t } = useTranslation();
|
||||
@@ -86,6 +87,16 @@ export function TopBar() {
|
||||
<div className="hidden md:block">
|
||||
<SearchDropdown placeholder={t('topbar.search')} />
|
||||
</div>
|
||||
{/* Command palette shortcut */}
|
||||
<button
|
||||
onClick={() => useCommandPaletteStore.getState().open()}
|
||||
className="p-2 rounded-md text-secondary-500 hover:bg-secondary-100 hover:text-secondary-700 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('topbar.commandPalette')}
|
||||
title={t('topbar.commandPaletteHint')}
|
||||
data-testid="command-palette-btn"
|
||||
>
|
||||
<Search className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Search, Loader2, Clock, X } from 'lucide-react';
|
||||
import { useGlobalSearch, SearchResult } from '@/api/hooks';
|
||||
import { useCommandPalette } from '@/hooks/useCommandPalette';
|
||||
|
||||
const RECENT_KEY = 'leocrm_recent_searches';
|
||||
const MAX_RECENT = 5;
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
company: 'search.companies',
|
||||
contact: 'search.contacts',
|
||||
mail: 'search.mails',
|
||||
file: 'search.files',
|
||||
event: 'search.events',
|
||||
message: 'search.messages',
|
||||
};
|
||||
|
||||
const TYPE_ICON_CLASSES: Record<string, string> = {
|
||||
company: 'bg-primary-100 text-primary-700',
|
||||
contact: 'bg-accent-100 text-accent-700',
|
||||
mail: 'bg-success-100 text-success-700',
|
||||
file: 'bg-warning-100 text-warning-700',
|
||||
event: 'bg-secondary-100 text-secondary-700',
|
||||
message: 'bg-secondary-100 text-secondary-700',
|
||||
};
|
||||
|
||||
function typeIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'company': return 'F';
|
||||
case 'contact': return 'K';
|
||||
case 'mail': return '@';
|
||||
case 'file': return '📄';
|
||||
case 'event': return '📅';
|
||||
case 'message': return '💬';
|
||||
default: return '?';
|
||||
}
|
||||
}
|
||||
|
||||
function loadRecent(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveRecent(query: string) {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) return;
|
||||
const next = [trimmed, ...loadRecent().filter((q) => q !== trimmed)].slice(0, MAX_RECENT);
|
||||
try {
|
||||
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { isOpen, close } = useCommandPalette();
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [recent, setRecent] = useState<string[]>([]);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const previouslyFocused = useRef<HTMLElement | null>(null);
|
||||
|
||||
const { data: results, isLoading, isError } = useGlobalSearch(debouncedQuery);
|
||||
|
||||
// Debounce query
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(query), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
// Load recent searches when opened
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setRecent(loadRecent());
|
||||
setQuery('');
|
||||
setDebouncedQuery('');
|
||||
setActiveIndex(-1);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Focus management + focus trap
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
previouslyFocused.current = document.activeElement as HTMLElement;
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
previouslyFocused.current?.focus();
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
close();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
// Simple focus trap: keep focus within the dialog
|
||||
const focusable = dialogRef.current?.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
||||
);
|
||||
if (!focusable || focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
}, [isOpen, close]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const flatResults = useMemo(() => results || [], [results]);
|
||||
|
||||
const groupedResults = useMemo(() => {
|
||||
const groups: Record<string, SearchResult[]> = {};
|
||||
for (const r of flatResults) {
|
||||
if (!groups[r.type]) groups[r.type] = [];
|
||||
groups[r.type].push(r);
|
||||
}
|
||||
return groups;
|
||||
}, [flatResults]);
|
||||
|
||||
const totalCount = flatResults.length;
|
||||
|
||||
const handleResultClick = (result: SearchResult) => {
|
||||
saveRecent(debouncedQuery);
|
||||
close();
|
||||
navigate(result.url);
|
||||
};
|
||||
|
||||
const handleRecentClick = (q: string) => {
|
||||
setQuery(q);
|
||||
setDebouncedQuery(q);
|
||||
setActiveIndex(-1);
|
||||
};
|
||||
|
||||
const handleInputKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.min(prev + 1, Math.max(totalCount - 1, 0)));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveIndex((prev) => Math.max(prev - 1, -1));
|
||||
} else if (e.key === 'Enter' && activeIndex >= 0 && flatResults[activeIndex]) {
|
||||
e.preventDefault();
|
||||
handleResultClick(flatResults[activeIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh] px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('commandPalette.title')}
|
||||
data-testid="command-palette"
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 bg-secondary-900/50"
|
||||
onClick={close}
|
||||
aria-hidden="true"
|
||||
data-testid="command-palette-backdrop"
|
||||
/>
|
||||
<div
|
||||
ref={dialogRef}
|
||||
className="relative w-full max-w-xl bg-white rounded-lg shadow-xl border border-secondary-200 overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-3 px-4 border-b border-secondary-200">
|
||||
<Search className="w-5 h-5 text-secondary-400 flex-shrink-0" aria-hidden="true" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => { setQuery(e.target.value); setActiveIndex(-1); }}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
placeholder={t('commandPalette.placeholder')}
|
||||
className="flex-1 py-3 text-base bg-transparent focus:outline-none text-secondary-900 placeholder-secondary-400"
|
||||
aria-label={t('commandPalette.placeholder')}
|
||||
role="combobox"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls="command-palette-results"
|
||||
aria-autocomplete="list"
|
||||
data-testid="command-palette-input"
|
||||
/>
|
||||
<button
|
||||
onClick={close}
|
||||
className="p-2 rounded-md text-secondary-400 hover:text-secondary-600 hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="command-palette-results"
|
||||
className="max-h-[50vh] overflow-y-auto"
|
||||
role="listbox"
|
||||
aria-label={t('commandPalette.results')}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-8 text-sm text-secondary-500" role="status">
|
||||
<Loader2 className="w-4 h-4 animate-spin" aria-hidden="true" />
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<div className="px-4 py-8 text-sm text-danger-600" role="alert">
|
||||
{t('commandPalette.error')}
|
||||
</div>
|
||||
) : debouncedQuery.trim() ? (
|
||||
totalCount === 0 ? (
|
||||
<div className="px-4 py-8 text-sm text-secondary-500">
|
||||
{t('commandPalette.noResults', { query: debouncedQuery })}
|
||||
</div>
|
||||
) : (
|
||||
Object.entries(groupedResults).map(([type, items]) => (
|
||||
<div key={type} className="py-1">
|
||||
<div className="px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-secondary-400">
|
||||
{t(TYPE_LABELS[type] || 'search.allTypes')}
|
||||
</div>
|
||||
<ul>
|
||||
{items.map((result, idx) => {
|
||||
const flatIdx = flatResults.indexOf(result);
|
||||
return (
|
||||
<li key={`${result.type}-${result.id}`}>
|
||||
<button
|
||||
onClick={() => handleResultClick(result)}
|
||||
onMouseEnter={() => setActiveIndex(flatIdx)}
|
||||
className={clsx(
|
||||
'w-full text-left px-4 py-2 flex items-center gap-3 min-h-touch hover:bg-secondary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
activeIndex === flatIdx && 'bg-primary-50'
|
||||
)}
|
||||
role="option"
|
||||
aria-selected={activeIndex === flatIdx}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center w-8 h-8 rounded-full text-xs font-semibold flex-shrink-0',
|
||||
TYPE_ICON_CLASSES[type] || 'bg-secondary-100 text-secondary-700'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{typeIcon(type)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-secondary-900 truncate">{result.name}</p>
|
||||
{result.description && (
|
||||
<p className="text-xs text-secondary-500 truncate">{result.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{typeof result.score === 'number' && result.score > 0 && (
|
||||
<span className="text-xs text-secondary-400 flex-shrink-0">
|
||||
{Math.round(result.score * 100)}%
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
))
|
||||
)
|
||||
) : recent.length > 0 ? (
|
||||
<div className="py-1">
|
||||
<div className="px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-secondary-400">
|
||||
{t('commandPalette.recent')}
|
||||
</div>
|
||||
<ul>
|
||||
{recent.map((q) => (
|
||||
<li key={q}>
|
||||
<button
|
||||
onClick={() => handleRecentClick(q)}
|
||||
className="w-full text-left px-4 py-2 flex items-center gap-3 min-h-touch hover:bg-secondary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
>
|
||||
<Clock className="w-4 h-4 text-secondary-400 flex-shrink-0" aria-hidden="true" />
|
||||
<span className="text-sm text-secondary-700 truncate">{q}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-8 text-sm text-secondary-500">
|
||||
{t('commandPalette.hint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2, Bookmark, BookmarkCheck } from 'lucide-react';
|
||||
import { SearchFilters } from '@/api/hooks';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
|
||||
const SAVED_KEY = 'leocrm_saved_searches';
|
||||
const MAX_SAVED = 20;
|
||||
|
||||
export interface SavedSearch {
|
||||
id: string;
|
||||
name: string;
|
||||
query: string;
|
||||
filters: SearchFilters;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
function loadSaved(): SavedSearch[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(SAVED_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function persistSaved(saved: SavedSearch[]) {
|
||||
try {
|
||||
localStorage.setItem(SAVED_KEY, JSON.stringify(saved));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
interface SavedSearchesProps {
|
||||
currentQuery: string;
|
||||
currentFilters: SearchFilters;
|
||||
onRun: (query: string, filters: SearchFilters) => void;
|
||||
}
|
||||
|
||||
export function SavedSearches({ currentQuery, currentFilters, onRun }: SavedSearchesProps) {
|
||||
const { t } = useTranslation();
|
||||
const [saved, setSaved] = useState<SavedSearch[]>([]);
|
||||
const [showSaveForm, setShowSaveForm] = useState(false);
|
||||
const [name, setName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
setSaved(loadSaved());
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => setSaved(loadSaved()), []);
|
||||
|
||||
const handleSave = () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || !currentQuery.trim()) return;
|
||||
const entry: SavedSearch = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
name: trimmed,
|
||||
query: currentQuery,
|
||||
filters: currentFilters,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const next = [entry, ...loadSaved()].slice(0, MAX_SAVED);
|
||||
persistSaved(next);
|
||||
setName('');
|
||||
setShowSaveForm(false);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
persistSaved(loadSaved().filter((s) => s.id !== id));
|
||||
refresh();
|
||||
};
|
||||
|
||||
const canSave = currentQuery.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="saved-searches">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-secondary-900">{t('search.savedSearches')}</h2>
|
||||
{canSave && !showSaveForm && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowSaveForm(true)} data-testid="save-search-btn">
|
||||
<Bookmark className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
{t('search.saveSearch')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSaveForm && (
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('search.saveSearchName')}
|
||||
aria-label={t('search.saveSearchName')}
|
||||
data-testid="save-search-name"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" onClick={handleSave} disabled={!name.trim()} data-testid="save-search-confirm">
|
||||
<BookmarkCheck className="w-4 h-4 mr-1" aria-hidden="true" />
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setShowSaveForm(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{saved.length === 0 ? (
|
||||
<p className="text-sm text-secondary-500">{t('search.noSavedSearches')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{saved.map((s) => (
|
||||
<li
|
||||
key={s.id}
|
||||
className="flex items-center gap-2 bg-white rounded-md border border-secondary-200 px-3 py-2"
|
||||
>
|
||||
<button
|
||||
onClick={() => onRun(s.query, s.filters)}
|
||||
className="flex-1 text-left min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 rounded-md"
|
||||
data-testid={`saved-search-${s.id}`}
|
||||
>
|
||||
<span className="block text-sm font-medium text-secondary-900 truncate">{s.name}</span>
|
||||
<span className="block text-xs text-secondary-500 truncate">{s.query}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(s.id)}
|
||||
className="p-2 rounded-md text-secondary-400 hover:text-danger-600 hover:bg-danger-50 min-h-touch min-w-touch flex items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-danger-500"
|
||||
aria-label={t('common.delete')}
|
||||
data-testid={`delete-saved-search-${s.id}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SearchFilters } from '@/api/hooks';
|
||||
import { fetchFacets, FacetsResponse } from '@/api/search';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
company: 'search.companies',
|
||||
contact: 'search.contacts',
|
||||
mail: 'search.mails',
|
||||
file: 'search.files',
|
||||
event: 'search.events',
|
||||
message: 'search.messages',
|
||||
};
|
||||
|
||||
interface SearchFacetsProps {
|
||||
filters: SearchFilters;
|
||||
onChange: (filters: SearchFilters) => void;
|
||||
onClear: () => void;
|
||||
entityCounts?: Record<string, number>;
|
||||
tagCounts?: Record<string, number>;
|
||||
}
|
||||
|
||||
export function SearchFacets({ filters, onChange, onClear, entityCounts, tagCounts }: SearchFacetsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [facets, setFacets] = useState<FacetsResponse | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchFacets()
|
||||
.then((data) => {
|
||||
if (!cancelled) setFacets(data);
|
||||
})
|
||||
.catch(() => {
|
||||
// ignore — facets are optional; the page still works without them
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const entityTypes = facets?.entity_types || [];
|
||||
const tags = facets?.tags || [];
|
||||
|
||||
const toggleEntityType = (type: string) => {
|
||||
const current = filters.entityTypes || [];
|
||||
const next = current.includes(type)
|
||||
? current.filter((x) => x !== type)
|
||||
: [...current, type];
|
||||
onChange({ ...filters, entityTypes: next.length > 0 ? next : undefined });
|
||||
};
|
||||
|
||||
const toggleTag = (tag: string) => {
|
||||
const current = filters.tags || [];
|
||||
const next = current.includes(tag)
|
||||
? current.filter((x) => x !== tag)
|
||||
: [...current, tag];
|
||||
onChange({ ...filters, tags: next.length > 0 ? next : undefined });
|
||||
};
|
||||
|
||||
const hasActiveFilters =
|
||||
(filters.entityTypes?.length || 0) > 0 ||
|
||||
(filters.tags?.length || 0) > 0 ||
|
||||
!!filters.dateFrom ||
|
||||
!!filters.dateTo ||
|
||||
(filters.sort && filters.sort !== 'relevance');
|
||||
|
||||
return (
|
||||
<div className="space-y-6" data-testid="search-facets">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-secondary-900">{t('search.filters')}</h2>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={onClear} data-testid="clear-filters-btn">
|
||||
{t('common.reset')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Entity type filter */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-secondary-400 mb-2">
|
||||
{t('search.entityType')}
|
||||
</h3>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-5" />)}
|
||||
</div>
|
||||
) : entityTypes.length === 0 ? (
|
||||
<p className="text-sm text-secondary-500">{t('common.none')}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{entityTypes.map((type) => {
|
||||
const checked = filters.entityTypes?.includes(type) || false;
|
||||
const count = entityCounts?.[type] ?? 0;
|
||||
return (
|
||||
<li key={type}>
|
||||
<label className="flex items-center gap-2 min-h-touch cursor-pointer hover:bg-secondary-50 rounded-md px-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleEntityType(type)}
|
||||
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={t(TYPE_LABELS[type] || 'search.allTypes')}
|
||||
/>
|
||||
<span className="flex-1 text-sm text-secondary-700">
|
||||
{t(TYPE_LABELS[type] || 'search.allTypes')}
|
||||
</span>
|
||||
{count > 0 && (
|
||||
<span className="text-xs text-secondary-400">{count}</span>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tag filter */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-secondary-400 mb-2">
|
||||
{t('search.tags')}
|
||||
</h3>
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-5" />)}
|
||||
</div>
|
||||
) : tags.length === 0 ? (
|
||||
<p className="text-sm text-secondary-500">{t('common.none')}</p>
|
||||
) : (
|
||||
<ul className="space-y-1">
|
||||
{tags.map((tag) => {
|
||||
const checked = filters.tags?.includes(tag) || false;
|
||||
const count = tagCounts?.[tag] ?? 0;
|
||||
return (
|
||||
<li key={tag}>
|
||||
<label className="flex items-center gap-2 min-h-touch cursor-pointer hover:bg-secondary-50 rounded-md px-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => toggleTag(tag)}
|
||||
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={tag}
|
||||
/>
|
||||
<span className="flex-1 text-sm text-secondary-700 truncate">{tag}</span>
|
||||
{count > 0 && (
|
||||
<span className="text-xs text-secondary-400">{count}</span>
|
||||
)}
|
||||
</label>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date range filter */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-secondary-400 mb-2">
|
||||
{t('search.dateRange')}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="date"
|
||||
label={t('search.dateFrom')}
|
||||
value={filters.dateFrom || ''}
|
||||
onChange={(e) => onChange({ ...filters, dateFrom: e.target.value || undefined })}
|
||||
data-testid="search-date-from"
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
label={t('search.dateTo')}
|
||||
value={filters.dateTo || ''}
|
||||
onChange={(e) => onChange({ ...filters, dateTo: e.target.value || undefined })}
|
||||
data-testid="search-date-to"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort selector */}
|
||||
<div>
|
||||
<h3 className="text-xs font-semibold uppercase tracking-wide text-secondary-400 mb-2">
|
||||
{t('common.sort')}
|
||||
</h3>
|
||||
<Select
|
||||
value={filters.sort || 'relevance'}
|
||||
onChange={(e) => onChange({ ...filters, sort: e.target.value as SearchFilters['sort'] })}
|
||||
options={[
|
||||
{ value: 'relevance', label: t('search.sortRelevance') },
|
||||
{ value: 'date', label: t('search.sortDate') },
|
||||
{ value: 'name', label: t('search.sortName') },
|
||||
]}
|
||||
aria-label={t('common.sort')}
|
||||
data-testid="search-sort"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { SearchResult } from '@/api/hooks';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
company: 'search.companies',
|
||||
contact: 'search.contacts',
|
||||
mail: 'search.mails',
|
||||
file: 'search.files',
|
||||
event: 'search.events',
|
||||
message: 'search.messages',
|
||||
};
|
||||
|
||||
const TYPE_ICON_CLASSES: Record<string, string> = {
|
||||
company: 'bg-primary-100 text-primary-700',
|
||||
contact: 'bg-accent-100 text-accent-700',
|
||||
mail: 'bg-success-100 text-success-700',
|
||||
file: 'bg-warning-100 text-warning-700',
|
||||
event: 'bg-secondary-100 text-secondary-700',
|
||||
message: 'bg-secondary-100 text-secondary-700',
|
||||
};
|
||||
|
||||
const TYPE_BADGE_VARIANTS: Record<string, 'primary' | 'info' | 'success' | 'warning' | 'default'> = {
|
||||
company: 'primary',
|
||||
contact: 'info',
|
||||
mail: 'success',
|
||||
file: 'warning',
|
||||
event: 'default',
|
||||
message: 'default',
|
||||
};
|
||||
|
||||
function typeIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'company': return 'F';
|
||||
case 'contact': return 'K';
|
||||
case 'mail': return '@';
|
||||
case 'file': return '📄';
|
||||
case 'event': return '📅';
|
||||
case 'message': return '💬';
|
||||
default: return '?';
|
||||
}
|
||||
}
|
||||
|
||||
interface SearchResultCardProps {
|
||||
result: SearchResult;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
function highlightMatch(text: string, query: string): React.ReactNode {
|
||||
if (!query.trim()) return text;
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const idx = lowerText.indexOf(lowerQuery);
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="bg-warning-200 text-secondary-900 rounded px-0.5">{text.slice(idx, idx + query.length)}</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SearchResultCard({ result, query }: SearchResultCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const score = typeof result.score === 'number' ? result.score : 0;
|
||||
const scorePct = Math.round(score * 100);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => navigate(result.url)}
|
||||
className="w-full text-left bg-white rounded-lg shadow-sm border border-secondary-200 p-4 flex items-start gap-4 hover:shadow-md hover:border-primary-300 transition-shadow min-h-touch focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500"
|
||||
data-testid={`search-result-${result.type}-${result.id}`}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center justify-center w-10 h-10 rounded-lg text-sm font-semibold flex-shrink-0',
|
||||
TYPE_ICON_CLASSES[result.type] || 'bg-secondary-100 text-secondary-700'
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{typeIcon(result.type)}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<p className="font-medium text-secondary-900 truncate">
|
||||
{highlightMatch(result.name, query || '')}
|
||||
</p>
|
||||
<Badge variant={TYPE_BADGE_VARIANTS[result.type] || 'default'}>
|
||||
{t(TYPE_LABELS[result.type] || 'search.allTypes')}
|
||||
</Badge>
|
||||
</div>
|
||||
{result.description && (
|
||||
<p className="text-sm text-secondary-500 mt-1 line-clamp-2">
|
||||
{highlightMatch(result.description, query || '')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{score > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center px-2 py-1 rounded-full text-xs font-medium bg-primary-100 text-primary-700 flex-shrink-0"
|
||||
title={t('search.score')}
|
||||
>
|
||||
{scorePct}%
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useCommandPaletteStore } from '@/store/commandPaletteStore';
|
||||
|
||||
/**
|
||||
* useCommandPalette — global command palette (Cmd+K / Ctrl+K) state.
|
||||
*
|
||||
* Backed by a shared Zustand store so the TopBar button and the
|
||||
* CommandPalette component stay in sync. Registers the global keyboard
|
||||
* shortcut listener once; typing inside inputs/textareas/contenteditable
|
||||
* is not hijacked.
|
||||
*/
|
||||
export function useCommandPalette() {
|
||||
const isOpen = useCommandPaletteStore((s) => s.isOpen);
|
||||
const open = useCommandPaletteStore((s) => s.open);
|
||||
const close = useCommandPaletteStore((s) => s.close);
|
||||
const toggle = useCommandPaletteStore((s) => s.toggle);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const isModifier = e.metaKey || e.ctrlKey;
|
||||
if (!isModifier || e.key.toLowerCase() !== 'k') return;
|
||||
|
||||
// Don't hijack typing inside editable fields
|
||||
const target = e.target as HTMLElement | null;
|
||||
const tag = target?.tagName?.toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || target?.isContentEditable) return;
|
||||
|
||||
e.preventDefault();
|
||||
useCommandPaletteStore.getState().toggle();
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return { isOpen, open, close, toggle };
|
||||
}
|
||||
@@ -329,7 +329,18 @@
|
||||
"enterQuery": "Geben Sie einen Suchbegriff ein.",
|
||||
"mails": "E-Mails",
|
||||
"files": "Dateien",
|
||||
"events": "Termine"
|
||||
"events": "Termine",
|
||||
"messages": "Nachrichten",
|
||||
"tags": "Tags",
|
||||
"dateRange": "Zeitraum",
|
||||
"sortRelevance": "Relevanz",
|
||||
"sortDate": "Datum",
|
||||
"sortName": "Name",
|
||||
"score": "Relevanz-Score",
|
||||
"savedSearches": "Gespeicherte Suchen",
|
||||
"saveSearch": "Suche speichern",
|
||||
"saveSearchName": "Name der Suche",
|
||||
"noSavedSearches": "Noch keine gespeicherten Suchen."
|
||||
},
|
||||
"topbar": {
|
||||
"tenant": "Mandant",
|
||||
@@ -339,7 +350,18 @@
|
||||
"userMenu": "Benutzermenü",
|
||||
"profile": "Profil",
|
||||
"settings": "Einstellungen",
|
||||
"logout": "Abmelden"
|
||||
"logout": "Abmelden",
|
||||
"commandPalette": "Befehlspalette öffnen",
|
||||
"commandPaletteHint": "Befehlspalette (Strg+K)"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Globale Suche",
|
||||
"placeholder": "Suchen... (Strg+K)",
|
||||
"results": "Suchergebnisse",
|
||||
"recent": "Letzte Suchen",
|
||||
"hint": "Geben Sie einen Suchbegriff ein oder drücken Sie Strg+K.",
|
||||
"noResults": "Keine Ergebnisse für \"{{query}}\" gefunden.",
|
||||
"error": "Suche fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"toast": {
|
||||
"success": "Erfolg",
|
||||
|
||||
@@ -329,7 +329,18 @@
|
||||
"enterQuery": "Enter a search term.",
|
||||
"mails": "Emails",
|
||||
"files": "Files",
|
||||
"events": "Events"
|
||||
"events": "Events",
|
||||
"messages": "Messages",
|
||||
"tags": "Tags",
|
||||
"dateRange": "Date Range",
|
||||
"sortRelevance": "Relevance",
|
||||
"sortDate": "Date",
|
||||
"sortName": "Name",
|
||||
"score": "Relevance Score",
|
||||
"savedSearches": "Saved Searches",
|
||||
"saveSearch": "Save Search",
|
||||
"saveSearchName": "Search name",
|
||||
"noSavedSearches": "No saved searches yet."
|
||||
},
|
||||
"topbar": {
|
||||
"tenant": "Tenant",
|
||||
@@ -339,7 +350,18 @@
|
||||
"userMenu": "User Menu",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings",
|
||||
"logout": "Sign Out"
|
||||
"logout": "Sign Out",
|
||||
"commandPalette": "Open command palette",
|
||||
"commandPaletteHint": "Command palette (Ctrl+K)"
|
||||
},
|
||||
"commandPalette": {
|
||||
"title": "Global Search",
|
||||
"placeholder": "Search... (Ctrl+K)",
|
||||
"results": "Search results",
|
||||
"recent": "Recent searches",
|
||||
"hint": "Type a search term or press Ctrl+K.",
|
||||
"noResults": "No results found for \"{{query}}\".",
|
||||
"error": "Search failed. Please try again."
|
||||
},
|
||||
"toast": {
|
||||
"success": "Success",
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
/**
|
||||
* Global search results page with tabs for companies/contacts/mails/files/events.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGlobalSearch, SearchResult } from '@/api/hooks';
|
||||
import { useGlobalSearch, SearchResult, SearchFilters } from '@/api/hooks';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { Tabs } from '@/components/shared/Tabs';
|
||||
|
||||
function highlightMatch(text: string, query: string): React.ReactNode {
|
||||
if (!query.trim()) return text;
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const idx = lowerText.indexOf(lowerQuery);
|
||||
if (idx === -1) return text;
|
||||
return (
|
||||
<>
|
||||
{text.slice(0, idx)}
|
||||
<mark className="bg-warning-200 text-secondary-900 rounded px-0.5">{text.slice(idx, idx + query.length)}</mark>
|
||||
{text.slice(idx + query.length)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
import { SearchFacets } from '@/components/search/SearchFacets';
|
||||
import { SearchResultCard } from '@/components/search/SearchResultCard';
|
||||
import { SavedSearches } from '@/components/search/SavedSearches';
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
company: 'search.companies',
|
||||
@@ -35,36 +18,34 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
mail: 'search.mails',
|
||||
file: 'search.files',
|
||||
event: 'search.events',
|
||||
message: 'search.messages',
|
||||
};
|
||||
|
||||
function renderResultIcon(type: string): React.ReactNode {
|
||||
const iconClass = 'w-10 h-10 rounded-lg flex items-center justify-center font-semibold flex-shrink-0';
|
||||
switch (type) {
|
||||
case 'company':
|
||||
return <div className={`${iconClass} bg-primary-100 text-primary-700`} aria-hidden="true">F</div>;
|
||||
case 'contact':
|
||||
return <div className={`${iconClass} bg-accent-100 text-accent-700`} aria-hidden="true">K</div>;
|
||||
case 'mail':
|
||||
return <div className={`${iconClass} bg-success-100 text-success-700`} aria-hidden="true">@</div>;
|
||||
case 'file':
|
||||
return <div className={`${iconClass} bg-warning-100 text-warning-700`} aria-hidden="true">📄</div>;
|
||||
case 'event':
|
||||
return <div className={`${iconClass} bg-secondary-100 text-secondary-700`} aria-hidden="true">📅</div>;
|
||||
default:
|
||||
return <div className={`${iconClass} bg-secondary-100 text-secondary-700`} aria-hidden="true">?</div>;
|
||||
}
|
||||
const EMPTY_FILTERS: SearchFilters = {};
|
||||
|
||||
function filtersToParams(filters: SearchFilters): Record<string, string> {
|
||||
const params: Record<string, string> = {};
|
||||
if (filters.entityTypes?.length) params.entity_types = filters.entityTypes.join(',');
|
||||
if (filters.tags?.length) params.tags = filters.tags.join(',');
|
||||
if (filters.dateFrom) params.date_from = filters.dateFrom;
|
||||
if (filters.dateTo) params.date_to = filters.dateTo;
|
||||
if (filters.sort && filters.sort !== 'relevance') params.sort = filters.sort;
|
||||
return params;
|
||||
}
|
||||
|
||||
function renderResultBadge(type: string, t: (s: string) => string): React.ReactNode {
|
||||
const labelKey = TYPE_LABELS[type] || 'search.allTypes';
|
||||
const variantMap: Record<string, 'primary' | 'info' | 'success' | 'warning' | 'default'> = {
|
||||
company: 'primary',
|
||||
contact: 'info',
|
||||
mail: 'success',
|
||||
file: 'warning',
|
||||
event: 'default',
|
||||
};
|
||||
return <Badge variant={variantMap[type] || 'default'}>{t(labelKey)}</Badge>;
|
||||
function paramsToFilters(params: URLSearchParams): SearchFilters {
|
||||
const filters: SearchFilters = {};
|
||||
const entityTypes = params.get('entity_types');
|
||||
const tags = params.get('tags');
|
||||
const dateFrom = params.get('date_from');
|
||||
const dateTo = params.get('date_to');
|
||||
const sort = params.get('sort');
|
||||
if (entityTypes) filters.entityTypes = entityTypes.split(',').filter(Boolean);
|
||||
if (tags) filters.tags = tags.split(',').filter(Boolean);
|
||||
if (dateFrom) filters.dateFrom = dateFrom;
|
||||
if (dateTo) filters.dateTo = dateTo;
|
||||
if (sort === 'date' || sort === 'name') filters.sort = sort;
|
||||
return filters;
|
||||
}
|
||||
|
||||
export function GlobalSearchResultsPage() {
|
||||
@@ -75,18 +56,39 @@ export function GlobalSearchResultsPage() {
|
||||
const query = searchParams.get('q') || '';
|
||||
const [searchInput, setSearchInput] = useState(query);
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [filters, setFilters] = useState<SearchFilters>(() => paramsToFilters(searchParams));
|
||||
|
||||
const { data: results, isLoading } = useGlobalSearch(query);
|
||||
const { data: results, isLoading } = useGlobalSearch(query, filters);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const params: Record<string, string> = {};
|
||||
if (searchInput) params.q = searchInput;
|
||||
setSearchParams(params);
|
||||
setSearchParams({ ...params, ...filtersToParams(filters) });
|
||||
};
|
||||
|
||||
const handleFiltersChange = useCallback((next: SearchFilters) => {
|
||||
setFilters(next);
|
||||
const params: Record<string, string> = {};
|
||||
if (searchInput) params.q = searchInput;
|
||||
setSearchParams({ ...params, ...filtersToParams(next) });
|
||||
}, [searchInput, setSearchParams]);
|
||||
|
||||
const handleClearFilters = useCallback(() => {
|
||||
setFilters(EMPTY_FILTERS);
|
||||
const params: Record<string, string> = {};
|
||||
if (searchInput) params.q = searchInput;
|
||||
setSearchParams(params);
|
||||
}, [searchInput, setSearchParams]);
|
||||
|
||||
const handleRunSaved = useCallback((savedQuery: string, savedFilters: SearchFilters) => {
|
||||
setSearchInput(savedQuery);
|
||||
setFilters(savedFilters);
|
||||
setSearchParams({ q: savedQuery, ...filtersToParams(savedFilters) });
|
||||
}, [setSearchParams]);
|
||||
|
||||
const groupedResults = useMemo(() => {
|
||||
const groups: Record<string, SearchResult[]> = { company: [], contact: [], mail: [], file: [], event: [] };
|
||||
const groups: Record<string, SearchResult[]> = { company: [], contact: [], mail: [], file: [], event: [], message: [] };
|
||||
if (results) {
|
||||
for (const r of results) {
|
||||
if (groups[r.type]) {
|
||||
@@ -97,9 +99,16 @@ export function GlobalSearchResultsPage() {
|
||||
return groups;
|
||||
}, [results]);
|
||||
|
||||
const totalResults = useMemo(() => {
|
||||
if (!results) return 0;
|
||||
return results.length;
|
||||
const totalResults = useMemo(() => (results ? results.length : 0), [results]);
|
||||
|
||||
const entityCounts = useMemo(() => {
|
||||
const counts: Record<string, number> = {};
|
||||
if (results) {
|
||||
for (const r of results) {
|
||||
counts[r.type] = (counts[r.type] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}, [results]);
|
||||
|
||||
const renderResults = (items: SearchResult[]) => {
|
||||
@@ -109,29 +118,7 @@ export function GlobalSearchResultsPage() {
|
||||
return (
|
||||
<div className="space-y-3" data-testid="search-results-list">
|
||||
{items.map((result) => (
|
||||
<Card key={`${result.type}-${result.id}`}>
|
||||
<button
|
||||
onClick={() => navigate(result.url)}
|
||||
className="w-full flex items-center gap-4 text-left hover:bg-secondary-50 p-2 rounded-md min-h-touch"
|
||||
data-testid={`search-result-${result.type}-${result.id}`}
|
||||
>
|
||||
{renderResultIcon(result.type)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-secondary-900">
|
||||
{highlightMatch(result.name, query)}
|
||||
</p>
|
||||
{renderResultBadge(result.type, t)}
|
||||
</div>
|
||||
{result.description && (
|
||||
<p className="text-sm text-secondary-500 truncate">
|
||||
{highlightMatch(result.description, query)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-secondary-400 text-sm" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</Card>
|
||||
<SearchResultCard key={`${result.type}-${result.id}`} result={result} query={query} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
@@ -213,17 +200,37 @@ export function GlobalSearchResultsPage() {
|
||||
|
||||
{!query ? (
|
||||
<EmptyState title={t('search.enterQuery')} />
|
||||
) : isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div data-testid="search-tabs">
|
||||
<Tabs tabs={tabs} defaultKey={activeTab} />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-[260px_1fr] gap-6">
|
||||
<aside className="space-y-6" aria-label={t('search.filters')}>
|
||||
<SearchFacets
|
||||
filters={filters}
|
||||
onChange={handleFiltersChange}
|
||||
onClear={handleClearFilters}
|
||||
entityCounts={entityCounts}
|
||||
/>
|
||||
<SavedSearches
|
||||
currentQuery={query}
|
||||
currentFilters={filters}
|
||||
onRun={handleRunSaved}
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div data-testid="search-tabs">
|
||||
<Tabs tabs={tabs} defaultKey={activeTab} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
interface CommandPaletteState {
|
||||
isOpen: boolean;
|
||||
open: () => void;
|
||||
close: () => void;
|
||||
toggle: () => void;
|
||||
}
|
||||
|
||||
export const useCommandPaletteStore = create<CommandPaletteState>((set) => ({
|
||||
isOpen: false,
|
||||
open: () => set({ isOpen: true }),
|
||||
close: () => set({ isOpen: false }),
|
||||
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
|
||||
}));
|
||||
Reference in New Issue
Block a user