2026-07-20 00:25:58 +02:00
|
|
|
import React, { useState } from 'react';
|
2026-07-19 21:30:26 +02:00
|
|
|
import clsx from 'clsx';
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
|
|
|
|
|
export type ContactFilter = 'all' | 'company' | 'person' | `tag:${string}`;
|
|
|
|
|
|
|
|
|
|
export interface ContactFolderTreeProps {
|
|
|
|
|
selectedFilter: ContactFilter;
|
|
|
|
|
onSelect: (filter: ContactFilter) => void;
|
|
|
|
|
tags: string[];
|
|
|
|
|
loading?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 00:25:58 +02:00
|
|
|
const icon = (path: string, cls = 'w-4 h-4') => (
|
|
|
|
|
<svg className={cls} fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
2026-07-19 21:30:26 +02:00
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={path} />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
|
2026-07-20 00:25:58 +02:00
|
|
|
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',
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-19 21:30:26 +02:00
|
|
|
export function ContactFolderTree({
|
|
|
|
|
selectedFilter,
|
|
|
|
|
onSelect,
|
|
|
|
|
tags,
|
|
|
|
|
loading,
|
|
|
|
|
}: ContactFolderTreeProps) {
|
|
|
|
|
const { t } = useTranslation();
|
2026-07-20 00:25:58 +02:00
|
|
|
const [tagsOpen, setTagsOpen] = useState(true);
|
2026-07-19 21:30:26 +02:00
|
|
|
|
2026-07-20 00:25:58 +02:00
|
|
|
const itemCls = (active: boolean) =>
|
|
|
|
|
clsx(
|
|
|
|
|
'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',
|
|
|
|
|
active ? 'bg-primary-50 text-primary-700' : 'text-secondary-700 hover:bg-secondary-100',
|
|
|
|
|
);
|
2026-07-19 21:30:26 +02:00
|
|
|
|
|
|
|
|
return (
|
2026-07-20 00:25:58 +02:00
|
|
|
<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>
|
2026-07-19 21:30:26 +02:00
|
|
|
|
2026-07-20 00:25:58 +02:00
|
|
|
{/* 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 */}
|
2026-07-19 21:30:26 +02:00
|
|
|
{tags.length > 0 && (
|
2026-07-20 00:25:58 +02:00
|
|
|
<div className="pt-1">
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setTagsOpen(!tagsOpen)}
|
|
|
|
|
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"
|
|
|
|
|
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) => {
|
|
|
|
|
const key = `tag:${tag}` as ContactFilter;
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
key={key}
|
|
|
|
|
onClick={() => onSelect(key)}
|
|
|
|
|
style={{ paddingLeft: '32px' }}
|
|
|
|
|
className={itemCls(selectedFilter === key)}
|
|
|
|
|
aria-current={selectedFilter === key ? 'true' : undefined}
|
|
|
|
|
>
|
|
|
|
|
{icon(ICONS.tag, 'w-3.5 h-3.5')}
|
|
|
|
|
<span className="truncate">{tag}</span>
|
|
|
|
|
</button>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-07-19 21:30:26 +02:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{loading && (
|
|
|
|
|
<div className="px-2 py-1 text-xs text-secondary-400">{t('common.loading')}</div>
|
|
|
|
|
)}
|
|
|
|
|
</nav>
|
|
|
|
|
);
|
|
|
|
|
}
|