UI: Suchfeld 10% niedriger, Kontakt-Baum mit Firmen/Privatpersonen Trennung, Toolbar vereinfacht (Sort+View Toggle)
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -11,12 +11,28 @@ export interface ContactFolderTreeProps {
|
|||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderIcon = (path: string) => (
|
const icon = (path: string, cls = 'w-4 h-4') => (
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const chevron = (open: boolean) => (
|
||||||
|
<svg
|
||||||
|
className={clsx('w-3.5 h-3.5 transition-transform flex-shrink-0', open && 'rotate-90')}
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
all: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
|
||||||
|
company: 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4',
|
||||||
|
person: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z',
|
||||||
|
tag: 'M7 7h.01M7 3h5a1.99 1.99 0 01.832.184l4 2A2 2 0 0118 7v10a2 2 0 01-2 2H7a2 2 0 01-2-2V5a2 2 0 012-2z',
|
||||||
|
};
|
||||||
|
|
||||||
export function ContactFolderTree({
|
export function ContactFolderTree({
|
||||||
selectedFilter,
|
selectedFilter,
|
||||||
onSelect,
|
onSelect,
|
||||||
@@ -24,74 +40,79 @@ export function ContactFolderTree({
|
|||||||
loading,
|
loading,
|
||||||
}: ContactFolderTreeProps) {
|
}: ContactFolderTreeProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [tagsOpen, setTagsOpen] = useState(true);
|
||||||
|
|
||||||
const folders: { key: ContactFilter; label: string; icon: React.ReactNode }[] = [
|
const itemCls = (active: boolean) =>
|
||||||
{
|
clsx(
|
||||||
key: 'all',
|
|
||||||
label: t('contacts.allContacts'),
|
|
||||||
icon: folderIcon('M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'company',
|
|
||||||
label: t('contacts.companies'),
|
|
||||||
icon: folderIcon('M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'person',
|
|
||||||
label: t('contacts.persons'),
|
|
||||||
icon: folderIcon('M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z'),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<nav className="space-y-1" aria-label={t('contacts.title')} data-testid="contact-folder-tree">
|
|
||||||
{folders.map((folder) => (
|
|
||||||
<button
|
|
||||||
key={folder.key}
|
|
||||||
onClick={() => onSelect(folder.key)}
|
|
||||||
className={clsx(
|
|
||||||
'flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm font-medium min-h-touch text-left',
|
'flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm font-medium min-h-touch text-left',
|
||||||
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||||
selectedFilter === folder.key
|
active ? 'bg-primary-50 text-primary-700' : 'text-secondary-700 hover:bg-secondary-100',
|
||||||
? 'bg-primary-50 text-primary-700'
|
);
|
||||||
: 'text-secondary-700 hover:bg-secondary-100',
|
|
||||||
)}
|
|
||||||
aria-current={selectedFilter === folder.key ? 'true' : undefined}
|
|
||||||
>
|
|
||||||
{folder.icon}
|
|
||||||
<span>{folder.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="space-y-0.5" aria-label={t('contacts.title')} data-testid="contact-folder-tree">
|
||||||
|
{/* Alle Kontakte */}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect('all')}
|
||||||
|
className={itemCls(selectedFilter === 'all')}
|
||||||
|
aria-current={selectedFilter === 'all' ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
{icon(ICONS.all)}
|
||||||
|
<span>{t('contacts.allContacts')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Firmen */}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect('company')}
|
||||||
|
className={itemCls(selectedFilter === 'company')}
|
||||||
|
aria-current={selectedFilter === 'company' ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
{icon(ICONS.company)}
|
||||||
|
<span>{t('contacts.companies')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Privatpersonen */}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect('person')}
|
||||||
|
className={itemCls(selectedFilter === 'person')}
|
||||||
|
aria-current={selectedFilter === 'person' ? 'true' : undefined}
|
||||||
|
>
|
||||||
|
{icon(ICONS.person)}
|
||||||
|
<span>{t('contacts.persons')}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Tags — collapsible */}
|
||||||
{tags.length > 0 && (
|
{tags.length > 0 && (
|
||||||
<>
|
<div className="pt-1">
|
||||||
<div className="my-2 border-t border-secondary-200" />
|
<button
|
||||||
<div className="px-2 py-1 text-xs font-semibold text-secondary-500 uppercase tracking-wide">
|
onClick={() => setTagsOpen(!tagsOpen)}
|
||||||
{t('tags.title')}
|
className="flex items-center gap-1.5 w-full px-2 py-1.5 rounded-md text-xs font-semibold text-secondary-500 uppercase tracking-wide hover:bg-secondary-100 min-h-touch text-left"
|
||||||
</div>
|
aria-expanded={tagsOpen}
|
||||||
|
>
|
||||||
|
{chevron(tagsOpen)}
|
||||||
|
{icon(ICONS.tag, 'w-3.5 h-3.5')}
|
||||||
|
<span>{t('tags.title')}</span>
|
||||||
|
</button>
|
||||||
|
{tagsOpen && (
|
||||||
|
<div className="mt-0.5 space-y-0.5">
|
||||||
{tags.map((tag) => {
|
{tags.map((tag) => {
|
||||||
const key = `tag:${tag}` as ContactFilter;
|
const key = `tag:${tag}` as ContactFilter;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
onClick={() => onSelect(key)}
|
onClick={() => onSelect(key)}
|
||||||
className={clsx(
|
style={{ paddingLeft: '32px' }}
|
||||||
'flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm font-medium min-h-touch text-left',
|
className={itemCls(selectedFilter === key)}
|
||||||
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
|
||||||
selectedFilter === key
|
|
||||||
? 'bg-primary-50 text-primary-700'
|
|
||||||
: 'text-secondary-700 hover:bg-secondary-100',
|
|
||||||
)}
|
|
||||||
aria-current={selectedFilter === key ? 'true' : undefined}
|
aria-current={selectedFilter === key ? 'true' : undefined}
|
||||||
>
|
>
|
||||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
{icon(ICONS.tag, 'w-3.5 h-3.5')}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5a1.99 1.99 0 01.832.184l4 2A2 2 0 0118 7v10a2 2 0 01-2 2H7a2 2 0 01-2-2V5a2 2 0 012-2z" />
|
|
||||||
</svg>
|
|
||||||
<span className="truncate">{tag}</span>
|
<span className="truncate">{tag}</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ export function SearchDropdown({ placeholder }: SearchDropdownProps) {
|
|||||||
onFocus={() => query && setOpen(true)}
|
onFocus={() => query && setOpen(true)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
placeholder={placeholder || t('topbar.search')}
|
placeholder={placeholder || t('topbar.search')}
|
||||||
className="w-64 pl-10 pr-3 py-1.5 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-touch"
|
className="w-64 pl-10 pr-3 py-1 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
aria-label={t('topbar.search')}
|
aria-label={t('topbar.search')}
|
||||||
role="combobox"
|
role="combobox"
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"filter": "Filtern",
|
"filter": "Filtern",
|
||||||
|
"sort": "Sortieren",
|
||||||
"reset": "Zurücksetzen",
|
"reset": "Zurücksetzen",
|
||||||
"export": "Exportieren",
|
"export": "Exportieren",
|
||||||
"import": "Importieren",
|
"import": "Importieren",
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"optional": "Optional",
|
"optional": "Optional",
|
||||||
"back": "Back",
|
"back": "Back",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
|
"sort": "Sort",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"import": "Import",
|
"import": "Import",
|
||||||
|
|||||||
@@ -6,10 +6,9 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import clsx from 'clsx';
|
||||||
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||||
import { Button } from '@/components/ui/Button';
|
|
||||||
import { Input } from '@/components/ui/Input';
|
import { Input } from '@/components/ui/Input';
|
||||||
import { Select } from '@/components/ui/Select';
|
|
||||||
import { useToast } from '@/components/ui/Toast';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import { EmptyState } from '@/components/ui/EmptyState';
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||||
@@ -232,33 +231,80 @@ export function ContactsListPage() {
|
|||||||
className="border-r border-secondary-200 bg-white"
|
className="border-r border-secondary-200 bg-white"
|
||||||
data-testid="contact-list-pane"
|
data-testid="contact-list-pane"
|
||||||
>
|
>
|
||||||
{/* Toolbar */}
|
{/* Toolbar — minimal: sort toggle + view toggle + new */}
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 bg-white flex-shrink-0">
|
<div className="flex items-center gap-1 px-3 py-1.5 border-b border-secondary-200 bg-white flex-shrink-0">
|
||||||
<Input
|
{/* Sort */}
|
||||||
type="search"
|
<div className="flex items-center gap-1">
|
||||||
value={search}
|
<select
|
||||||
onChange={(e) => handleSearch(e.target.value)}
|
|
||||||
placeholder={t('contacts.searchPlaceholder')}
|
|
||||||
aria-label={t('common.search')}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={viewMode}
|
|
||||||
onChange={(e) => setViewMode(e.target.value as ContactViewMode)}
|
|
||||||
options={viewModeOptions}
|
|
||||||
aria-label={t('contacts.viewMode')}
|
|
||||||
className="w-auto"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => { setSortBy(e.target.value); setPage(1); }}
|
onChange={(e) => { setSortBy(e.target.value); setPage(1); }}
|
||||||
options={sortOptions}
|
className="text-xs border border-secondary-300 rounded-md px-2 py-1 bg-white text-secondary-700 focus:outline-none focus:ring-1 focus:ring-primary-500"
|
||||||
aria-label={t('common.filter')}
|
aria-label={t('common.sort')}
|
||||||
className="w-auto"
|
>
|
||||||
/>
|
{sortOptions.map((opt) => (
|
||||||
<Button size="sm" onClick={handleCreate} data-testid="contacts-new-btn">
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
{t('contacts.create')}
|
))}
|
||||||
</Button>
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => handleSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc')}
|
||||||
|
className="p-1.5 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
|
||||||
|
aria-label={sortOrder === 'asc' ? t('common.sortDesc') : t('common.sortAsc')}
|
||||||
|
title={sortOrder === 'asc' ? 'Absteigend' : 'Aufsteigend'}
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5 text-secondary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={sortOrder === 'asc' ? 'M3 4h13M3 8h9M3 12h5m13 0l-4 4m4-4l-4-4m4 4v6' : 'M3 4h13M3 8h9M3 12h5m13-4v6m0 0l-4-4m4 4l-4 4'} />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* View mode toggle */}
|
||||||
|
<div className="flex items-center gap-0.5 bg-secondary-100 rounded-md p-0.5">
|
||||||
|
{viewModeOptions.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
onClick={() => setViewMode(opt.value as ContactViewMode)}
|
||||||
|
className={clsx(
|
||||||
|
'px-2 py-1 rounded text-xs font-medium transition-colors min-h-touch',
|
||||||
|
viewMode === opt.value
|
||||||
|
? 'bg-white text-primary-600 shadow-sm'
|
||||||
|
: 'text-secondary-500 hover:text-secondary-700',
|
||||||
|
)}
|
||||||
|
aria-label={opt.label}
|
||||||
|
aria-pressed={viewMode === opt.value}
|
||||||
|
>
|
||||||
|
{opt.value === 'list' && (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{opt.value === 'table' && (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4h4v4H4V4zm6 0h4v4h-4V4zm6 0h4v4h-4V4zM4 10h4v4H4v-4zm6 0h4v4h-4v-4zm6 0h4v4h-4v-4zM4 16h4v4H4v-4zm6 0h4v4h-4v-4zm6 0h4v4h-4v-4z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{opt.value === 'cards' && (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5h6v6H4V5zm10 0h6v6h-6V5zM4 13h6v6H4v-6zm10 0h6v6h-6v-6z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* New contact */}
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className="p-1.5 rounded-md hover:bg-secondary-100 min-h-touch min-w-touch flex items-center justify-center"
|
||||||
|
aria-label={t('contacts.create')}
|
||||||
|
title={t('contacts.create')}
|
||||||
|
data-testid="contacts-new-btn"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4 text-primary-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List */}
|
{/* List */}
|
||||||
|
|||||||
Reference in New Issue
Block a user