Frontend: unified 3-column contacts page (folder tree | list/table/cards | detail), all Rentman fields, ContactPerson CRUD, removed old Contact/Company pages

This commit is contained in:
Agent Zero
2026-07-19 21:30:26 +02:00
parent cf75680583
commit 3177daf47f
25 changed files with 1869 additions and 1592 deletions
@@ -0,0 +1,102 @@
import React from 'react';
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;
}
const folderIcon = (path: string) => (
<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={path} />
</svg>
);
export function ContactFolderTree({
selectedFilter,
onSelect,
tags,
loading,
}: ContactFolderTreeProps) {
const { t } = useTranslation();
const folders: { key: ContactFilter; label: string; icon: React.ReactNode }[] = [
{
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',
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
selectedFilter === folder.key
? '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>
))}
{tags.length > 0 && (
<>
<div className="my-2 border-t border-secondary-200" />
<div className="px-2 py-1 text-xs font-semibold text-secondary-500 uppercase tracking-wide">
{t('tags.title')}
</div>
{tags.map((tag) => {
const key = `tag:${tag}` as ContactFilter;
return (
<button
key={key}
onClick={() => onSelect(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',
'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}
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<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>
</button>
);
})}
</>
)}
{loading && (
<div className="px-2 py-1 text-xs text-secondary-400">{t('common.loading')}</div>
)}
</nav>
);
}