2026-07-01 20:43:49 +02:00
|
|
|
/**
|
|
|
|
|
* Global search results page with tabs for companies/contacts/mails/files/events.
|
|
|
|
|
*/
|
|
|
|
|
|
2026-06-29 11:01:39 +02:00
|
|
|
import React, { useState, useMemo } from 'react';
|
|
|
|
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
import { useGlobalSearch, SearchResult } 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';
|
2026-07-01 20:43:49 +02:00
|
|
|
import { Tabs } from '@/components/shared/Tabs';
|
2026-06-29 11:01:39 +02:00
|
|
|
|
|
|
|
|
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)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 20:43:49 +02:00
|
|
|
const TYPE_LABELS: Record<string, string> = {
|
|
|
|
|
company: 'search.companies',
|
|
|
|
|
contact: 'search.contacts',
|
|
|
|
|
mail: 'search.mails',
|
|
|
|
|
file: 'search.files',
|
|
|
|
|
event: 'search.events',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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>;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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>;
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 11:01:39 +02:00
|
|
|
export function GlobalSearchResultsPage() {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
|
|
|
|
|
|
|
|
const query = searchParams.get('q') || '';
|
|
|
|
|
const [searchInput, setSearchInput] = useState(query);
|
2026-07-01 20:43:49 +02:00
|
|
|
const [activeTab, setActiveTab] = useState('all');
|
2026-06-29 11:01:39 +02:00
|
|
|
|
2026-07-01 20:43:49 +02:00
|
|
|
const { data: results, isLoading } = useGlobalSearch(query);
|
2026-06-29 11:01:39 +02:00
|
|
|
|
|
|
|
|
const handleSearch = (e: React.FormEvent) => {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
const params: Record<string, string> = {};
|
|
|
|
|
if (searchInput) params.q = searchInput;
|
|
|
|
|
setSearchParams(params);
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-01 20:43:49 +02:00
|
|
|
const groupedResults = useMemo(() => {
|
|
|
|
|
const groups: Record<string, SearchResult[]> = { company: [], contact: [], mail: [], file: [], event: [] };
|
|
|
|
|
if (results) {
|
|
|
|
|
for (const r of results) {
|
|
|
|
|
if (groups[r.type]) {
|
|
|
|
|
groups[r.type].push(r);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return groups;
|
|
|
|
|
}, [results]);
|
|
|
|
|
|
|
|
|
|
const totalResults = useMemo(() => {
|
|
|
|
|
if (!results) return 0;
|
|
|
|
|
return results.length;
|
|
|
|
|
}, [results]);
|
|
|
|
|
|
|
|
|
|
const renderResults = (items: SearchResult[]) => {
|
|
|
|
|
if (items.length === 0) {
|
|
|
|
|
return <EmptyState title={t('search.noResults', { query })} />;
|
2026-06-29 11:01:39 +02:00
|
|
|
}
|
2026-07-01 20:43:49 +02:00
|
|
|
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>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const tabs = useMemo(() => [
|
|
|
|
|
{
|
|
|
|
|
key: 'all',
|
|
|
|
|
label: t('search.allTypes'),
|
|
|
|
|
badge: totalResults,
|
|
|
|
|
content: (
|
|
|
|
|
<div className="space-y-3" data-testid="search-results-all">
|
|
|
|
|
{totalResults === 0 && !isLoading ? (
|
|
|
|
|
<EmptyState title={t('search.noResults', { query })} />
|
|
|
|
|
) : (
|
|
|
|
|
renderResults(results || [])
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'company',
|
|
|
|
|
label: t('search.companies'),
|
|
|
|
|
badge: groupedResults.company.length,
|
|
|
|
|
content: <div data-testid="search-results-company">{renderResults(groupedResults.company)}</div>,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'contact',
|
|
|
|
|
label: t('search.contacts'),
|
|
|
|
|
badge: groupedResults.contact.length,
|
|
|
|
|
content: <div data-testid="search-results-contact">{renderResults(groupedResults.contact)}</div>,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'mail',
|
|
|
|
|
label: t('search.mails'),
|
|
|
|
|
badge: groupedResults.mail.length,
|
|
|
|
|
content: <div data-testid="search-results-mail">{renderResults(groupedResults.mail)}</div>,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'file',
|
|
|
|
|
label: t('search.files'),
|
|
|
|
|
badge: groupedResults.file.length,
|
|
|
|
|
content: <div data-testid="search-results-file">{renderResults(groupedResults.file)}</div>,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'event',
|
|
|
|
|
label: t('search.events'),
|
|
|
|
|
badge: groupedResults.event.length,
|
|
|
|
|
content: <div data-testid="search-results-event">{renderResults(groupedResults.event)}</div>,
|
|
|
|
|
},
|
|
|
|
|
], [t, totalResults, groupedResults, results, isLoading, query, navigate]);
|
2026-06-29 11:01:39 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="p-6 max-w-7xl mx-auto" data-testid="global-search-page">
|
|
|
|
|
<h1 className="text-2xl font-bold text-secondary-900 mb-6">{t('search.title')}</h1>
|
|
|
|
|
|
|
|
|
|
<Card title={t('search.filters')} className="mb-6">
|
2026-07-01 20:43:49 +02:00
|
|
|
<form onSubmit={handleSearch} className="flex gap-3">
|
|
|
|
|
<div className="flex-1">
|
2026-06-29 11:01:39 +02:00
|
|
|
<Input
|
|
|
|
|
label={t('common.search')}
|
|
|
|
|
value={searchInput}
|
|
|
|
|
onChange={(e) => setSearchInput(e.target.value)}
|
|
|
|
|
placeholder={t('common.search')}
|
|
|
|
|
data-testid="search-input"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-07-01 20:43:49 +02:00
|
|
|
<div className="flex items-end">
|
2026-06-29 11:01:39 +02:00
|
|
|
<Button type="submit" data-testid="search-submit-btn">{t('common.search')}</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</form>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{query && (
|
|
|
|
|
<p className="text-sm text-secondary-600 mb-4" data-testid="search-query-display">
|
|
|
|
|
{t('search.resultsFor', { query })}:
|
|
|
|
|
</p>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{!query ? (
|
|
|
|
|
<EmptyState title={t('search.enterQuery')} />
|
|
|
|
|
) : isLoading ? (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{[1, 2, 3].map((i) => (
|
|
|
|
|
<Skeleton key={i} className="h-16" />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2026-07-01 20:43:49 +02:00
|
|
|
<div data-testid="search-tabs">
|
|
|
|
|
<Tabs tabs={tabs} defaultKey={activeTab} />
|
2026-06-29 11:01:39 +02:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2026-07-01 20:43:49 +02:00
|
|
|
}
|