2026-07-19 21:30:26 +02:00
|
|
|
import React from 'react';
|
|
|
|
|
import clsx from 'clsx';
|
|
|
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
|
import { Badge } from '@/components/ui/Badge';
|
|
|
|
|
import { Pagination } from '@/components/ui/Pagination';
|
|
|
|
|
import { EmptyState } from '@/components/ui/EmptyState';
|
|
|
|
|
import type { UnifiedContact } from '@/api/hooks';
|
|
|
|
|
|
|
|
|
|
export type ContactViewMode = 'list' | 'table' | 'cards';
|
|
|
|
|
|
|
|
|
|
export interface ContactListProps {
|
|
|
|
|
contacts: UnifiedContact[];
|
|
|
|
|
selectedContactId: string | null;
|
|
|
|
|
onSelectContact: (contact: UnifiedContact) => void;
|
|
|
|
|
loading?: boolean;
|
|
|
|
|
currentPage: number;
|
|
|
|
|
total: number;
|
|
|
|
|
pageSize: number;
|
|
|
|
|
onPageChange: (page: number) => void;
|
|
|
|
|
viewMode: ContactViewMode;
|
|
|
|
|
sortBy: string;
|
|
|
|
|
sortOrder: 'asc' | 'desc';
|
|
|
|
|
onSortChange: (sortBy: string, sortOrder: 'asc' | 'desc') => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TypeBadge({ type }: { type: string }) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
return (
|
|
|
|
|
<Badge variant={type === 'company' ? 'primary' : 'info'}>
|
|
|
|
|
{type === 'company' ? t('contacts.companies') : t('contacts.persons')}
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDisplayName(c: UnifiedContact): string {
|
|
|
|
|
return c.displayname || c.name || [c.firstname, c.surname].filter(Boolean).join(' ') || c.email_1 || c.id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getInitials(c: UnifiedContact): string {
|
|
|
|
|
const name = getDisplayName(c);
|
|
|
|
|
return name.charAt(0).toUpperCase();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCity(c: UnifiedContact): string {
|
|
|
|
|
return c.mailing_city || c.visit_city || c.invoice_city || '—';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getEmail(c: UnifiedContact): string {
|
|
|
|
|
return c.email_1 || c.email_2 || '—';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getPhone(c: UnifiedContact): string {
|
|
|
|
|
return c.phone_1 || c.phone_2 || '—';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTags(c: UnifiedContact): string[] {
|
|
|
|
|
if (!c.tags) return [];
|
|
|
|
|
return c.tags.split(',').map((t) => t.trim()).filter(Boolean);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ContactList({
|
|
|
|
|
contacts,
|
|
|
|
|
selectedContactId,
|
|
|
|
|
onSelectContact,
|
|
|
|
|
loading,
|
|
|
|
|
currentPage,
|
|
|
|
|
total,
|
|
|
|
|
pageSize,
|
|
|
|
|
onPageChange,
|
|
|
|
|
viewMode,
|
|
|
|
|
sortBy,
|
|
|
|
|
sortOrder,
|
|
|
|
|
onSortChange,
|
|
|
|
|
}: ContactListProps) {
|
|
|
|
|
const { t } = useTranslation();
|
|
|
|
|
|
|
|
|
|
if (loading) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex items-center justify-center py-12" data-testid="contact-list-loading">
|
|
|
|
|
<svg className="animate-spin h-5 w-5 text-secondary-400" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
|
|
|
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
|
|
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
|
|
|
</svg>
|
|
|
|
|
<span className="ml-2 text-sm text-secondary-500">{t('common.loading')}</span>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (contacts.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div data-testid="contact-list-empty">
|
|
|
|
|
<EmptyState title={t('contacts.emptyTitle')} description={t('contacts.emptyDescription')} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const totalPages = Math.ceil(total / pageSize);
|
|
|
|
|
|
|
|
|
|
const handleSort = (field: string) => {
|
|
|
|
|
if (sortBy === field) {
|
|
|
|
|
onSortChange(field, sortOrder === 'asc' ? 'desc' : 'asc');
|
|
|
|
|
} else {
|
|
|
|
|
onSortChange(field, 'asc');
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── List View ──
|
|
|
|
|
if (viewMode === 'list') {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col h-full" data-testid="contact-list-view">
|
|
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
|
|
|
<ul className="divide-y divide-secondary-100" role="list">
|
|
|
|
|
{contacts.map((contact) => (
|
|
|
|
|
<li key={contact.id}>
|
|
|
|
|
<button
|
2026-07-20 01:33:44 +02:00
|
|
|
draggable
|
|
|
|
|
onDragStart={(e) => {
|
|
|
|
|
e.dataTransfer.setData('text/plain', contact.id);
|
|
|
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
|
|
|
}}
|
2026-07-19 21:30:26 +02:00
|
|
|
onClick={() => onSelectContact(contact)}
|
|
|
|
|
className={clsx(
|
|
|
|
|
'flex items-center gap-3 w-full px-3 py-2.5 text-left min-h-touch transition-colors',
|
|
|
|
|
selectedContactId === contact.id
|
|
|
|
|
? 'bg-primary-50'
|
|
|
|
|
: 'hover:bg-secondary-50',
|
|
|
|
|
)}
|
|
|
|
|
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
|
|
|
|
>
|
|
|
|
|
<div className="w-9 h-9 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold text-sm flex-shrink-0">
|
|
|
|
|
{getInitials(contact)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
|
|
|
|
|
<TypeBadge type={contact.type} />
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-secondary-500 truncate">
|
|
|
|
|
{getEmail(contact)} · {getCity(contact)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
<Pagination
|
|
|
|
|
currentPage={currentPage}
|
|
|
|
|
totalPages={totalPages}
|
|
|
|
|
total={total}
|
|
|
|
|
pageSize={pageSize}
|
|
|
|
|
onPageChange={onPageChange}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Table View ──
|
|
|
|
|
if (viewMode === 'table') {
|
|
|
|
|
const sortIcon = (field: string) => {
|
|
|
|
|
if (sortBy !== field) return null;
|
|
|
|
|
return sortOrder === 'asc' ? ' ▲' : ' ▼';
|
|
|
|
|
};
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col h-full" data-testid="contact-table-view">
|
|
|
|
|
<div className="flex-1 overflow-auto">
|
|
|
|
|
<table className="w-full text-sm">
|
|
|
|
|
<thead className="sticky top-0 bg-white border-b border-secondary-200">
|
|
|
|
|
<tr>
|
|
|
|
|
<th className="px-3 py-2 text-left font-medium text-secondary-600 w-10">
|
|
|
|
|
<input type="checkbox" className="rounded border-secondary-300" aria-label={t('common.all')} />
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
className="px-3 py-2 text-left font-medium text-secondary-600 cursor-pointer select-none"
|
|
|
|
|
onClick={() => handleSort('type')}
|
|
|
|
|
aria-sort={sortBy === 'type' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'}
|
|
|
|
|
>
|
|
|
|
|
{t('contacts.type')}{sortIcon('type')}
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
className="px-3 py-2 text-left font-medium text-secondary-600 cursor-pointer select-none"
|
|
|
|
|
onClick={() => handleSort('displayname')}
|
|
|
|
|
aria-sort={sortBy === 'displayname' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'}
|
|
|
|
|
>
|
|
|
|
|
{t('contacts.fullName')}{sortIcon('displayname')}
|
|
|
|
|
</th>
|
|
|
|
|
<th
|
|
|
|
|
className="px-3 py-2 text-left font-medium text-secondary-600 cursor-pointer select-none"
|
|
|
|
|
onClick={() => handleSort('email_1')}
|
|
|
|
|
aria-sort={sortBy === 'email_1' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'}
|
|
|
|
|
>
|
|
|
|
|
{t('contacts.email')}{sortIcon('email_1')}
|
|
|
|
|
</th>
|
|
|
|
|
<th className="px-3 py-2 text-left font-medium text-secondary-600">{t('contacts.phone')}</th>
|
|
|
|
|
<th
|
|
|
|
|
className="px-3 py-2 text-left font-medium text-secondary-600 cursor-pointer select-none"
|
|
|
|
|
onClick={() => handleSort('mailing_city')}
|
|
|
|
|
aria-sort={sortBy === 'mailing_city' ? (sortOrder === 'asc' ? 'ascending' : 'descending') : 'none'}
|
|
|
|
|
>
|
|
|
|
|
{t('address.city')}{sortIcon('mailing_city')}
|
|
|
|
|
</th>
|
|
|
|
|
<th className="px-3 py-2 text-left font-medium text-secondary-600">{t('tags.title')}</th>
|
|
|
|
|
</tr>
|
|
|
|
|
</thead>
|
|
|
|
|
<tbody className="divide-y divide-secondary-100">
|
|
|
|
|
{contacts.map((contact) => (
|
|
|
|
|
<tr
|
|
|
|
|
key={contact.id}
|
2026-07-20 01:33:44 +02:00
|
|
|
draggable
|
|
|
|
|
onDragStart={(e) => {
|
|
|
|
|
e.dataTransfer.setData('text/plain', contact.id);
|
|
|
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
|
|
|
}}
|
2026-07-19 21:30:26 +02:00
|
|
|
onClick={() => onSelectContact(contact)}
|
|
|
|
|
className={clsx(
|
|
|
|
|
'cursor-pointer transition-colors',
|
|
|
|
|
selectedContactId === contact.id ? 'bg-primary-50' : 'hover:bg-secondary-50',
|
|
|
|
|
)}
|
|
|
|
|
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
|
|
|
|
>
|
|
|
|
|
<td className="px-3 py-2" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
<input type="checkbox" className="rounded border-secondary-300" aria-label={getDisplayName(contact)} />
|
|
|
|
|
</td>
|
|
|
|
|
<td className="px-3 py-2"><TypeBadge type={contact.type} /></td>
|
|
|
|
|
<td className="px-3 py-2 font-medium text-secondary-900">{getDisplayName(contact)}</td>
|
|
|
|
|
<td className="px-3 py-2 text-secondary-600">{getEmail(contact)}</td>
|
|
|
|
|
<td className="px-3 py-2 text-secondary-600">{getPhone(contact)}</td>
|
|
|
|
|
<td className="px-3 py-2 text-secondary-600">{getCity(contact)}</td>
|
|
|
|
|
<td className="px-3 py-2">
|
|
|
|
|
<div className="flex flex-wrap gap-1">
|
|
|
|
|
{getTags(contact).slice(0, 3).map((tag) => (
|
|
|
|
|
<Badge key={tag} variant="secondary">{tag}</Badge>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</td>
|
|
|
|
|
</tr>
|
|
|
|
|
))}
|
|
|
|
|
</tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</div>
|
|
|
|
|
<Pagination
|
|
|
|
|
currentPage={currentPage}
|
|
|
|
|
totalPages={totalPages}
|
|
|
|
|
total={total}
|
|
|
|
|
pageSize={pageSize}
|
|
|
|
|
onPageChange={onPageChange}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Cards View ──
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex flex-col h-full" data-testid="contact-cards-view">
|
|
|
|
|
<div className="flex-1 overflow-y-auto p-3">
|
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
|
|
|
{contacts.map((contact) => (
|
|
|
|
|
<div
|
|
|
|
|
key={contact.id}
|
2026-07-20 01:33:44 +02:00
|
|
|
draggable
|
|
|
|
|
onDragStart={(e) => {
|
|
|
|
|
e.dataTransfer.setData('text/plain', contact.id);
|
|
|
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
|
|
|
}}
|
2026-07-19 21:30:26 +02:00
|
|
|
onClick={() => onSelectContact(contact)}
|
|
|
|
|
className={clsx(
|
|
|
|
|
'p-3 rounded-lg border cursor-pointer transition-colors min-h-touch',
|
|
|
|
|
selectedContactId === contact.id
|
|
|
|
|
? 'border-primary-300 bg-primary-50'
|
|
|
|
|
: 'border-secondary-200 bg-white hover:bg-secondary-50',
|
|
|
|
|
)}
|
|
|
|
|
role="button"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
aria-current={selectedContactId === contact.id ? 'true' : undefined}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-3 mb-2">
|
|
|
|
|
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold flex-shrink-0">
|
|
|
|
|
{getInitials(contact)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex-1 min-w-0">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<span className="font-medium text-sm text-secondary-900 truncate">{getDisplayName(contact)}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<TypeBadge type={contact.type} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-xs text-secondary-500 space-y-0.5">
|
|
|
|
|
<div className="truncate">{getEmail(contact)}</div>
|
|
|
|
|
<div className="truncate">{getPhone(contact)}</div>
|
|
|
|
|
<div className="truncate">{getCity(contact)}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<Pagination
|
|
|
|
|
currentPage={currentPage}
|
|
|
|
|
totalPages={totalPages}
|
|
|
|
|
total={total}
|
|
|
|
|
pageSize={pageSize}
|
|
|
|
|
onPageChange={onPageChange}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|