229 lines
7.3 KiB
TypeScript
229 lines
7.3 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { useState, useEffect, useCallback } from 'react';
|
||
|
|
import { useRouter } from 'next/navigation';
|
||
|
|
import { Table } from '@/components/ui/Table';
|
||
|
|
import { Button } from '@/components/ui/Button';
|
||
|
|
import { Input } from '@/components/ui/Input';
|
||
|
|
import {
|
||
|
|
listContacts,
|
||
|
|
type ContactResponse,
|
||
|
|
type ContactListParams,
|
||
|
|
} from '@/lib/contacts';
|
||
|
|
import type { PaginatedResponse } from '@/lib/api';
|
||
|
|
|
||
|
|
const ROLE_OPTIONS = ['kaeufer', 'verkaeufer', 'beide'];
|
||
|
|
const SORT_OPTIONS = [
|
||
|
|
{ value: '-created_at', label: 'Newest First' },
|
||
|
|
{ value: 'created_at', label: 'Oldest First' },
|
||
|
|
{ value: 'company_name', label: 'Company A-Z' },
|
||
|
|
{ value: '-company_name', label: 'Company Z-A' },
|
||
|
|
{ value: 'address_city', label: 'City A-Z' },
|
||
|
|
];
|
||
|
|
|
||
|
|
export function ContactList() {
|
||
|
|
const router = useRouter();
|
||
|
|
const [contacts, setContacts] = useState<ContactResponse[]>([]);
|
||
|
|
const [total, setTotal] = useState(0);
|
||
|
|
const [page, setPage] = useState(1);
|
||
|
|
const [pageSize] = useState(20);
|
||
|
|
const [loading, setLoading] = useState(false);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
|
||
|
|
const [filters, setFilters] = useState<ContactListParams>({
|
||
|
|
page: 1,
|
||
|
|
page_size: 20,
|
||
|
|
});
|
||
|
|
|
||
|
|
const fetchContacts = useCallback(async () => {
|
||
|
|
setLoading(true);
|
||
|
|
setError(null);
|
||
|
|
try {
|
||
|
|
const data: PaginatedResponse<ContactResponse> = await listContacts(filters);
|
||
|
|
setContacts(data.items);
|
||
|
|
setTotal(data.total);
|
||
|
|
setPage(data.page);
|
||
|
|
} catch (err: unknown) {
|
||
|
|
const apiErr = err as { error?: { message?: string } };
|
||
|
|
setError(apiErr?.error?.message || 'Failed to load contacts');
|
||
|
|
} finally {
|
||
|
|
setLoading(false);
|
||
|
|
}
|
||
|
|
}, [filters]);
|
||
|
|
|
||
|
|
useEffect(() => {
|
||
|
|
fetchContacts();
|
||
|
|
}, [fetchContacts]);
|
||
|
|
|
||
|
|
const handleFilterChange = (key: keyof ContactListParams, value: string) => {
|
||
|
|
setFilters(prev => ({
|
||
|
|
...prev,
|
||
|
|
page: 1,
|
||
|
|
[key]: value || undefined,
|
||
|
|
}));
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleEuToggle = (value: string) => {
|
||
|
|
if (value === '') {
|
||
|
|
setFilters(prev => ({ ...prev, page: 1, is_eu: undefined }));
|
||
|
|
} else {
|
||
|
|
setFilters(prev => ({ ...prev, page: 1, is_eu: value === 'eu' }));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const handlePageChange = (newPage: number) => {
|
||
|
|
setFilters(prev => ({ ...prev, page: newPage }));
|
||
|
|
};
|
||
|
|
|
||
|
|
const columns = [
|
||
|
|
{
|
||
|
|
key: 'company_name',
|
||
|
|
label: 'Company',
|
||
|
|
render: (row: ContactResponse) => (
|
||
|
|
<button
|
||
|
|
data-testid={`contact-row-${row.id}`}
|
||
|
|
onClick={() => router.push(`/de/kontakte/${row.id}`)}
|
||
|
|
className="text-primary hover:underline"
|
||
|
|
>
|
||
|
|
{row.company_name}
|
||
|
|
</button>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{ key: 'address_city', label: 'City' },
|
||
|
|
{ key: 'address_country', label: 'Country' },
|
||
|
|
{ key: 'role', label: 'Role' },
|
||
|
|
{
|
||
|
|
key: 'vat_id',
|
||
|
|
label: 'VAT ID',
|
||
|
|
render: (row: ContactResponse) => row.vat_id || '—',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'vat_id_status',
|
||
|
|
label: 'VAT Status',
|
||
|
|
render: (row: ContactResponse) => (
|
||
|
|
<span
|
||
|
|
className={`px-2 py-1 rounded text-xs ${
|
||
|
|
row.vat_id_status === 'geprueft' || row.vat_id_status === 'manuell_bestaetigt'
|
||
|
|
? 'bg-success/20 text-success'
|
||
|
|
: row.vat_id_status === 'ungueltig'
|
||
|
|
? 'bg-error/20 text-error'
|
||
|
|
: 'bg-secondary/20 text-secondary'
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{row.vat_id_status}
|
||
|
|
</span>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
key: 'contact_persons',
|
||
|
|
label: 'Persons',
|
||
|
|
render: (row: ContactResponse) => String(row.contact_persons?.length || 0),
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
const totalPages = Math.ceil(total / pageSize);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div data-testid="contact-list" className="space-y-4">
|
||
|
|
<div className="flex items-center justify-between">
|
||
|
|
<h1 className="text-2xl font-bold text-text">Kontakte</h1>
|
||
|
|
<Button onClick={() => router.push('/de/kontakte/neu')} data-testid="new-contact-btn">
|
||
|
|
+ Neuer Kontakt
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div data-testid="contact-filters" className="flex flex-wrap gap-3 p-4 bg-surface rounded-lg border border-border">
|
||
|
|
<div className="w-48">
|
||
|
|
<label className="block text-sm font-medium text-text mb-1">Search</label>
|
||
|
|
<Input
|
||
|
|
data-testid="filter-search"
|
||
|
|
type="text"
|
||
|
|
placeholder="Company, city, email..."
|
||
|
|
value={filters.search || ''}
|
||
|
|
onChange={e => handleFilterChange('search', e.target.value)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
<div className="w-40">
|
||
|
|
<label className="block text-sm font-medium text-text mb-1">Role</label>
|
||
|
|
<select
|
||
|
|
data-testid="filter-role"
|
||
|
|
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||
|
|
value={filters.role || ''}
|
||
|
|
onChange={e => handleFilterChange('role', e.target.value)}
|
||
|
|
>
|
||
|
|
<option value="">All</option>
|
||
|
|
{ROLE_OPTIONS.map(r => (
|
||
|
|
<option key={r} value={r}>{r}</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<div className="w-40">
|
||
|
|
<label className="block text-sm font-medium text-text mb-1">Region</label>
|
||
|
|
<select
|
||
|
|
data-testid="filter-eu"
|
||
|
|
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||
|
|
value={filters.is_eu === undefined ? '' : filters.is_eu ? 'eu' : 'inland'}
|
||
|
|
onChange={e => handleEuToggle(e.target.value)}
|
||
|
|
>
|
||
|
|
<option value="">All</option>
|
||
|
|
<option value="inland">Inland (DE)</option>
|
||
|
|
<option value="eu">EU (non-DE)</option>
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
<div className="w-40">
|
||
|
|
<label className="block text-sm font-medium text-text mb-1">Sort</label>
|
||
|
|
<select
|
||
|
|
data-testid="filter-sort"
|
||
|
|
className="w-full px-3 py-2 border rounded-lg bg-surface text-text border-border"
|
||
|
|
value={filters.sort || ''}
|
||
|
|
onChange={e => handleFilterChange('sort', e.target.value)}
|
||
|
|
>
|
||
|
|
<option value="">Default</option>
|
||
|
|
{SORT_OPTIONS.map(s => (
|
||
|
|
<option key={s.value} value={s.value}>{s.label}</option>
|
||
|
|
))}
|
||
|
|
</select>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{error && (
|
||
|
|
<div data-testid="contact-error" className="p-4 bg-error/10 text-error rounded-lg">
|
||
|
|
{error}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{loading ? (
|
||
|
|
<div data-testid="contact-loading" className="text-center py-8 text-text-muted">
|
||
|
|
Loading contacts...
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<Table columns={columns} data={contacts} rowKey={row => row.id} />
|
||
|
|
)}
|
||
|
|
|
||
|
|
{totalPages > 1 && (
|
||
|
|
<div data-testid="contact-pagination" className="flex items-center justify-between">
|
||
|
|
<span className="text-sm text-text-muted">
|
||
|
|
Page {page} of {totalPages} ({total} total)
|
||
|
|
</span>
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<Button
|
||
|
|
variant="secondary"
|
||
|
|
disabled={page <= 1}
|
||
|
|
onClick={() => handlePageChange(page - 1)}
|
||
|
|
>
|
||
|
|
Previous
|
||
|
|
</Button>
|
||
|
|
<Button
|
||
|
|
variant="secondary"
|
||
|
|
disabled={page >= totalPages}
|
||
|
|
onClick={() => handlePageChange(page + 1)}
|
||
|
|
>
|
||
|
|
Next
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|