T07b: frontend feature pages — companies + contacts + settings + audit + dashboard + search
- 11 new feature pages (CompaniesList/Detail/Form, ContactsList/Detail/Form, SettingsProfile/Roles/Users, AuditLog, GlobalSearchResults) - 3 page updates (Dashboard with StatCard+ActivityFeed, Settings with tree nav+Outlet, TopBar with SearchDropdown) - 13 new routes in routes/index.tsx - i18n updates (de.json + en.json) with companies/contacts/settings/audit/search keys - 12 new test files + 2 existing test fixes (TopBar, AppShell) - 7 shared components (DataGrid, Tabs, SearchDropdown, CsvImportDialog, StatCard, ActivityFeed, UnsavedChangesGuard) - 16 new API hooks in hooks.ts - Verification: 141 tests pass, build succeeds, tsc --noEmit clean
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
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';
|
||||
|
||||
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 GlobalSearchResultsPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
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 entityTypes = useMemo(() => {
|
||||
if (entityType === 'all') return undefined;
|
||||
return [entityType];
|
||||
}, [entityType]);
|
||||
|
||||
const { data: results, isLoading } = useGlobalSearch(query, entityTypes);
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
return filtered;
|
||||
}, [results, dateFrom, dateTo]);
|
||||
|
||||
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">
|
||||
<Input
|
||||
label={t('common.search')}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
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">
|
||||
<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>
|
||||
) : 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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user