T08c: Frontend Mail UI + Global Search UI — 44 tests, tsc clean, vite build pass

- Mail page: 3-pane layout (folder tree + mail list + reading pane)
- Compose modal: rich text editor (bold/italic/link), template picker, reply/forward pre-fill
- Mail settings: accounts, signatures, rules, labels, vacation, PGP (6 tabs)
- Shared mailbox selector: switch between personal + shared accounts
- Mail search bar + attachment download + create-event-from-mail
- Global search: tabs for companies/contacts/mails/files/events
- Search autocomplete in TopBar (existing SearchDropdown)
- API client: mail.ts (all endpoints)
- Routes: /mail, /mail/settings
- i18n: de.json + en.json mail + search translations
- 44 new tests (4 test files), full regression 318/318 pass
- tsc --noEmit: 0 errors, vite build: 267 modules
This commit is contained in:
leocrm-bot
2026-07-01 20:43:49 +02:00
parent 0962f3a961
commit 0070fb3aea
30 changed files with 4312 additions and 191 deletions
+148 -84
View File
@@ -1,14 +1,18 @@
/**
* Global search results page with tabs for companies/contacts/mails/files/events.
*/
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 { Select } from '@/components/ui/Select';
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;
@@ -25,6 +29,44 @@ function highlightMatch(text: string, query: string): React.ReactNode {
);
}
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>;
}
export function GlobalSearchResultsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
@@ -32,45 +74,123 @@ export function GlobalSearchResultsPage() {
const query = searchParams.get('q') || '';
const [searchInput, setSearchInput] = useState(query);
const [entityType, setEntityType] = useState(searchParams.get('type') || 'all');
const [dateFrom, setDateFrom] = useState(searchParams.get('dateFrom') || '');
const [dateTo, setDateTo] = useState(searchParams.get('dateTo') || '');
const [activeTab, setActiveTab] = useState('all');
const entityTypes = useMemo(() => {
if (entityType === 'all') return undefined;
return [entityType];
}, [entityType]);
const { data: results, isLoading } = useGlobalSearch(query, entityTypes);
const { data: results, isLoading } = useGlobalSearch(query);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const params: Record<string, string> = {};
if (searchInput) params.q = searchInput;
if (entityType !== 'all') params.type = entityType;
if (dateFrom) params.dateFrom = dateFrom;
if (dateTo) params.dateTo = dateTo;
setSearchParams(params);
};
const filteredResults = useMemo(() => {
if (!results) return [];
let filtered = results;
if (dateFrom) {
filtered = filtered.filter((r) => {
return true;
});
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 filtered;
}, [results, dateFrom, dateTo]);
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 })} />;
}
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]);
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">
<form onSubmit={handleSearch} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<form onSubmit={handleSearch} className="flex gap-3">
<div className="flex-1">
<Input
label={t('common.search')}
value={searchInput}
@@ -78,30 +198,8 @@ export function GlobalSearchResultsPage() {
placeholder={t('common.search')}
data-testid="search-input"
/>
<Select
label={t('search.entityType')}
options={[
{ value: 'all', label: t('search.allTypes') },
{ value: 'company', label: t('search.companies') },
{ value: 'contact', label: t('search.contacts') },
]}
value={entityType}
onChange={(e) => setEntityType(e.target.value)}
/>
<Input
label={t('search.dateFrom')}
type="date"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
<Input
label={t('search.dateTo')}
type="date"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
<div className="flex justify-end">
<div className="flex items-end">
<Button type="submit" data-testid="search-submit-btn">{t('common.search')}</Button>
</div>
</form>
@@ -121,45 +219,11 @@ export function GlobalSearchResultsPage() {
<Skeleton key={i} className="h-16" />
))}
</div>
) : filteredResults.length === 0 ? (
<EmptyState
title={t('search.noResults', { query })}
/>
) : (
<div className="space-y-3" data-testid="search-results-list">
{filteredResults.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}`}
>
<div className={`w-10 h-10 rounded-lg flex items-center justify-center font-semibold flex-shrink-0 ${
result.type === 'company' ? 'bg-primary-100 text-primary-700' : 'bg-accent-100 text-accent-700'
}`} aria-hidden="true">
{result.type === 'company' ? 'F' : 'K'}
</div>
<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>
<Badge variant={result.type === 'company' ? 'primary' : 'info'}>
{result.type === 'company' ? t('search.companies') : t('search.contacts')}
</Badge>
</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 data-testid="search-tabs">
<Tabs tabs={tabs} defaultKey={activeTab} />
</div>
)}
</div>
);
}
}