150 lines
4.6 KiB
TypeScript
150 lines
4.6 KiB
TypeScript
|
|
import React, { useState, useMemo } from 'react';
|
||
|
|
import { useNavigate } from 'react-router-dom';
|
||
|
|
import { useTranslation } from 'react-i18next';
|
||
|
|
import { ColumnDef } from '@tanstack/react-table';
|
||
|
|
import { useContacts, useDeleteContact, Contact } from '@/api/hooks';
|
||
|
|
import { DataGrid } from '@/components/shared/DataGrid';
|
||
|
|
import { Button } from '@/components/ui/Button';
|
||
|
|
import { Input } from '@/components/ui/Input';
|
||
|
|
import { EmptyState } from '@/components/ui/EmptyState';
|
||
|
|
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
|
||
|
|
import { useToast } from '@/components/ui/Toast';
|
||
|
|
import { Avatar } from '@/components/ui/Avatar';
|
||
|
|
|
||
|
|
export function ContactsListPage() {
|
||
|
|
const { t } = useTranslation();
|
||
|
|
const navigate = useNavigate();
|
||
|
|
const toast = useToast();
|
||
|
|
|
||
|
|
const [page, setPage] = useState(1);
|
||
|
|
const [pageSize] = useState(25);
|
||
|
|
const [search, setSearch] = useState('');
|
||
|
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||
|
|
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
|
||
|
|
|
||
|
|
const { data, isLoading } = useContacts(page, pageSize, debouncedSearch);
|
||
|
|
const deleteMutation = useDeleteContact();
|
||
|
|
|
||
|
|
React.useEffect(() => {
|
||
|
|
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||
|
|
return () => clearTimeout(timer);
|
||
|
|
}, [search]);
|
||
|
|
|
||
|
|
const columns = useMemo<ColumnDef<Contact, any>[]>(
|
||
|
|
() => [
|
||
|
|
{
|
||
|
|
id: 'name',
|
||
|
|
header: t('contacts.fullName'),
|
||
|
|
cell: ({ row }) => (
|
||
|
|
<div className="flex items-center gap-2">
|
||
|
|
<Avatar name={`${row.original.first_name} ${row.original.last_name}`} size="sm" />
|
||
|
|
<span className="font-medium text-primary-700">
|
||
|
|
{row.original.first_name} {row.original.last_name}
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
accessorKey: 'email',
|
||
|
|
header: t('contacts.email'),
|
||
|
|
cell: (info) =>
|
||
|
|
info.getValue() ? (
|
||
|
|
<a href={`mailto:${info.getValue()}`} className="text-primary-600 hover:text-primary-700">
|
||
|
|
{info.getValue()}
|
||
|
|
</a>
|
||
|
|
) : (
|
||
|
|
'—'
|
||
|
|
),
|
||
|
|
},
|
||
|
|
{
|
||
|
|
accessorKey: 'phone',
|
||
|
|
header: t('contacts.phone'),
|
||
|
|
cell: (info) => info.getValue() || '—',
|
||
|
|
},
|
||
|
|
{
|
||
|
|
accessorKey: 'position',
|
||
|
|
header: t('contacts.position'),
|
||
|
|
cell: (info) => info.getValue() || '—',
|
||
|
|
},
|
||
|
|
],
|
||
|
|
[t]
|
||
|
|
);
|
||
|
|
|
||
|
|
const handleDelete = async () => {
|
||
|
|
if (!deleteTarget) return;
|
||
|
|
try {
|
||
|
|
await deleteMutation.mutateAsync(deleteTarget.id);
|
||
|
|
toast.success(t('contacts.deleted'));
|
||
|
|
setDeleteTarget(null);
|
||
|
|
} catch (err: any) {
|
||
|
|
toast.error(err.message || t('common.error'));
|
||
|
|
}
|
||
|
|
};
|
||
|
|
|
||
|
|
const contacts = data?.items ?? [];
|
||
|
|
const total = data?.total ?? 0;
|
||
|
|
const isEmpty = !isLoading && contacts.length === 0 && !debouncedSearch;
|
||
|
|
|
||
|
|
if (isEmpty) {
|
||
|
|
return (
|
||
|
|
<div className="p-6 max-w-7xl mx-auto" data-testid="contacts-list-page">
|
||
|
|
<div className="flex items-center justify-between mb-6">
|
||
|
|
<h1 className="text-2xl font-bold text-secondary-900">{t('contacts.title')}</h1>
|
||
|
|
</div>
|
||
|
|
<EmptyState
|
||
|
|
title={t('contacts.emptyTitle')}
|
||
|
|
description={t('contacts.emptyDescription')}
|
||
|
|
action={
|
||
|
|
<Button onClick={() => navigate('/contacts/new')}>
|
||
|
|
{t('contacts.create')}
|
||
|
|
</Button>
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="p-6 max-w-7xl mx-auto" data-testid="contacts-list-page">
|
||
|
|
<div className="flex items-center justify-between mb-6">
|
||
|
|
<h1 className="text-2xl font-bold text-secondary-900">{t('contacts.title')}</h1>
|
||
|
|
<Button onClick={() => navigate('/contacts/new')}>
|
||
|
|
{t('contacts.create')}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div className="mb-4">
|
||
|
|
<Input
|
||
|
|
type="search"
|
||
|
|
value={search}
|
||
|
|
onChange={(e) => setSearch(e.target.value)}
|
||
|
|
placeholder={t('common.search')}
|
||
|
|
aria-label={t('common.search')}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<DataGrid
|
||
|
|
columns={columns}
|
||
|
|
data={contacts}
|
||
|
|
total={total}
|
||
|
|
page={page}
|
||
|
|
pageSize={pageSize}
|
||
|
|
onPageChange={setPage}
|
||
|
|
onRowClick={(row) => navigate(`/contacts/${row.id}`)}
|
||
|
|
loading={isLoading}
|
||
|
|
emptyMessage={t('common.noResults')}
|
||
|
|
testId="contacts-grid"
|
||
|
|
/>
|
||
|
|
|
||
|
|
<ConfirmDialog
|
||
|
|
open={!!deleteTarget}
|
||
|
|
title={t('contacts.deleteConfirmTitle')}
|
||
|
|
message={t('contacts.deleteConfirm')}
|
||
|
|
variant="danger"
|
||
|
|
onConfirm={handleDelete}
|
||
|
|
onCancel={() => setDeleteTarget(null)}
|
||
|
|
/>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|