T07b: frontend feature pages — companies + contacts + settings + audit + dashboard + search
- 11 new feature pages (CompaniesList/Detail/Form, ContactsList/Detail/Form, SettingsProfile/Roles/Users, AuditLog, GlobalSearchResults) - 3 page updates (Dashboard with StatCard+ActivityFeed, Settings with tree nav+Outlet, TopBar with SearchDropdown) - 13 new routes in routes/index.tsx - i18n updates (de.json + en.json) with companies/contacts/settings/audit/search keys - 12 new test files + 2 existing test fixes (TopBar, AppShell) - 7 shared components (DataGrid, Tabs, SearchDropdown, CsvImportDialog, StatCard, ActivityFeed, UnsavedChangesGuard) - 16 new API hooks in hooks.ts - Verification: 141 tests pass, build succeeds, tsc --noEmit clean
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user