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:
@@ -1,194 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ColumnDef } from '@tanstack/react-table';
|
||||
import { useCompanies, useCompanyExport, useDeleteCompany, Company } from '@/api/hooks';
|
||||
import { DataGrid } from '@/components/shared/DataGrid';
|
||||
import { CsvImportDialog } from '@/components/shared/CsvImportDialog';
|
||||
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 { Badge } from '@/components/ui/Badge';
|
||||
|
||||
export function CompaniesListPage() {
|
||||
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 [importOpen, setImportOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Company | null>(null);
|
||||
|
||||
const { data, isLoading } = useCompanies(page, pageSize, debouncedSearch);
|
||||
const exportMutation = useCompanyExport(debouncedSearch);
|
||||
const deleteMutation = useDeleteCompany();
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [search]);
|
||||
|
||||
const columns = useMemo<ColumnDef<Company, any>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: t('companies.name'),
|
||||
cell: (info) => (
|
||||
<span className="font-medium text-primary-700 hover:text-primary-800">
|
||||
{info.getValue()}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'account_number',
|
||||
header: t('companies.accountNumber'),
|
||||
cell: (info) => info.getValue() || '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'industry',
|
||||
header: t('companies.industry'),
|
||||
cell: (info) =>
|
||||
info.getValue() ? (
|
||||
<Badge variant="primary">{info.getValue()}</Badge>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'phone',
|
||||
header: t('companies.phone'),
|
||||
cell: (info) => info.getValue() || '—',
|
||||
},
|
||||
{
|
||||
accessorKey: 'email',
|
||||
header: t('companies.email'),
|
||||
cell: (info) =>
|
||||
info.getValue() ? (
|
||||
<a href={`mailto:${info.getValue()}`} className="text-primary-600 hover:text-primary-700">
|
||||
{info.getValue()}
|
||||
</a>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const blob = await exportMutation.mutateAsync();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `companies_${new Date().toISOString().split('T')[0]}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success(t('companies.export') + ' — OK');
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync(deleteTarget.id);
|
||||
toast.success(t('companies.deleted'));
|
||||
setDeleteTarget(null);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const companies = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const isEmpty = !isLoading && companies.length === 0 && !debouncedSearch;
|
||||
|
||||
if (isEmpty) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="companies-list-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('companies.title')}</h1>
|
||||
</div>
|
||||
<EmptyState
|
||||
title={t('companies.emptyTitle')}
|
||||
description={t('companies.emptyDescription')}
|
||||
action={
|
||||
<Button onClick={() => navigate('/companies/new')}>
|
||||
{t('companies.create')}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="companies-list-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{t('companies.title')}</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => setImportOpen(true)}>
|
||||
{t('companies.import')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleExport}
|
||||
isLoading={exportMutation.isPending}
|
||||
>
|
||||
{t('companies.export')}
|
||||
</Button>
|
||||
<Button onClick={() => navigate('/companies/new')}>
|
||||
{t('companies.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</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={companies}
|
||||
total={total}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
onPageChange={setPage}
|
||||
onRowClick={(row) => navigate(`/companies/${row.id}`)}
|
||||
loading={isLoading}
|
||||
emptyMessage={t('common.noResults')}
|
||||
testId="companies-grid"
|
||||
/>
|
||||
|
||||
<CsvImportDialog
|
||||
open={importOpen}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onSuccess={() => setPage(1)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deleteTarget}
|
||||
title={t('companies.deleteConfirmTitle')}
|
||||
message={t('companies.deleteConfirm')}
|
||||
variant="danger"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCompany } from '@/api/hooks';
|
||||
import { Tabs, TabItem } from '@/components/shared/Tabs';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { TagPicker } from '@/components/tags/TagPicker';
|
||||
import { AddressList } from '@/components/AddressList';
|
||||
|
||||
export function CompanyDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { data: company, isLoading, isError } = useCompany(id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="company-detail-page">
|
||||
<Skeleton className="h-8 w-64 mb-6" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !company) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="company-detail-page">
|
||||
<EmptyState
|
||||
title={t('companies.notFound')}
|
||||
action={<Button onClick={() => navigate('/companies')}>{t('common.back')}</Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const contacts = company.contacts ?? [];
|
||||
|
||||
const overviewTab: TabItem = {
|
||||
key: 'overview',
|
||||
label: t('companies.overview'),
|
||||
content: (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card title={t('companies.name')}>
|
||||
<p className="text-secondary-900">{company.name}</p>
|
||||
</Card>
|
||||
<Card title={t('companies.accountNumber')}>
|
||||
<p className="text-secondary-900">{company.account_number || '—'}</p>
|
||||
</Card>
|
||||
<Card title={t('companies.industry')}>
|
||||
{company.industry ? <Badge variant="primary">{company.industry}</Badge> : <p className="text-secondary-500">—</p>}
|
||||
</Card>
|
||||
<Card title={t('companies.phone')}>
|
||||
<p className="text-secondary-900">{company.phone || '—'}</p>
|
||||
</Card>
|
||||
<Card title={t('companies.email')}>
|
||||
{company.email ? (
|
||||
<a href={`mailto:${company.email}`} className="text-primary-600 hover:text-primary-700">{company.email}</a>
|
||||
) : <p className="text-secondary-500">—</p>}
|
||||
</Card>
|
||||
<Card title={t('companies.website')}>
|
||||
{company.website ? (
|
||||
<a href={company.website} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:text-primary-700">{company.website}</a>
|
||||
) : <p className="text-secondary-500">—</p>}
|
||||
</Card>
|
||||
{company.description && (
|
||||
<Card title={t('companies.description')}>
|
||||
<p className="text-secondary-700 text-sm whitespace-pre-wrap">{company.description}</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const contactsTab: TabItem = {
|
||||
key: 'contacts',
|
||||
label: t('companies.contacts'),
|
||||
badge: contacts.length,
|
||||
content: contacts.length === 0 ? (
|
||||
<EmptyState title={t('companies.noContacts')} />
|
||||
) : (
|
||||
<ul className="space-y-2" role="list">
|
||||
{contacts.map((contact) => (
|
||||
<li key={contact.id}>
|
||||
<button
|
||||
onClick={() => navigate(`/contacts/${contact.id}`)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-secondary-50 text-left min-h-touch"
|
||||
>
|
||||
<Avatar name={`${contact.first_name} ${contact.last_name}`} size="sm" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-secondary-900">{contact.first_name} {contact.last_name}</p>
|
||||
<p className="text-sm text-secondary-500 truncate">{contact.email}{contact.position ? ` · ${contact.position}` : ''}</p>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
};
|
||||
|
||||
const addressesTab: TabItem = {
|
||||
key: 'addresses',
|
||||
label: t('address.title'),
|
||||
content: (
|
||||
<AddressList entityType="company" entityId={company.id} />
|
||||
),
|
||||
};
|
||||
|
||||
const tagsTab: TabItem = {
|
||||
key: 'tags',
|
||||
label: t('tags.title'),
|
||||
content: (
|
||||
<TagPicker entityType="company" entityId={company.id} />
|
||||
),
|
||||
};
|
||||
|
||||
const filesTab: TabItem = {
|
||||
key: 'files',
|
||||
label: t('companies.files'),
|
||||
content: <EmptyState title={t('companies.noFiles')} />,
|
||||
};
|
||||
|
||||
const activityTab: TabItem = {
|
||||
key: 'activity',
|
||||
label: t('companies.activity'),
|
||||
content: <EmptyState title={t('companies.noActivity')} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="company-detail-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate('/companies')} aria-label={t('common.back')}>
|
||||
← {t('common.back')}
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{company.name}</h1>
|
||||
</div>
|
||||
<Button onClick={() => navigate(`/companies/${company.id}/edit`)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
<Tabs tabs={[overviewTab, contactsTab, addressesTab, tagsTab, filesTab, activityTab]} defaultKey="overview" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useCompany, useCreateCompany, useUpdateCompany } from '@/api/hooks';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { UnsavedChangesGuard } from '@/components/shared/UnsavedChangesGuard';
|
||||
|
||||
const companySchema = z.object({
|
||||
name: z.string().min(1, 'Name ist erforderlich').max(100, 'Maximal 100 Zeichen'),
|
||||
account_number: z.string().max(40, 'Maximal 40 Zeichen').optional().or(z.literal('')),
|
||||
industry: z.string().max(50, 'Maximal 50 Zeichen').optional().or(z.literal('')),
|
||||
phone: z.string().max(30, 'Maximal 30 Zeichen').optional().or(z.literal('')),
|
||||
email: z.string().email('Ungültige E-Mail-Adresse').max(255).optional().or(z.literal('')),
|
||||
website: z.string().max(500, 'Maximal 500 Zeichen').optional().or(z.literal('')),
|
||||
description: z.string().optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
type CompanyFormValues = z.infer<typeof companySchema>;
|
||||
|
||||
export function CompanyFormPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isEdit = !!id;
|
||||
|
||||
const { data: company, isLoading } = useCompany(isEdit ? id : undefined);
|
||||
const createMutation = useCreateCompany();
|
||||
const updateMutation = useUpdateCompany();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
} = useForm<CompanyFormValues>({
|
||||
resolver: zodResolver(companySchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
account_number: '',
|
||||
industry: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
website: '',
|
||||
description: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && company) {
|
||||
reset({
|
||||
name: company.name || '',
|
||||
account_number: company.account_number || '',
|
||||
industry: company.industry || '',
|
||||
phone: company.phone || '',
|
||||
email: company.email || '',
|
||||
website: company.website || '',
|
||||
description: company.description || '',
|
||||
});
|
||||
}
|
||||
}, [company, isEdit, reset]);
|
||||
|
||||
const onSubmit = async (values: CompanyFormValues) => {
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateMutation.mutateAsync({ id, data: values });
|
||||
toast.success(t('companies.updated'));
|
||||
navigate(`/companies/${id}`);
|
||||
} else {
|
||||
const result = await createMutation.mutateAsync(values) as { id: string };
|
||||
toast.success(t('companies.created'));
|
||||
navigate(`/companies/${result.id}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || (isEdit ? t('companies.updateFailed') : t('companies.createFailed')));
|
||||
}
|
||||
};
|
||||
|
||||
if (isEdit && isLoading) {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="company-form-page">
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="company-form-page">
|
||||
<UnsavedChangesGuard isDirty={isDirty} />
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate(-1)}>
|
||||
← {t('common.back')}
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">
|
||||
{isEdit ? t('companies.edit') : t('companies.create')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<Card title={t('companies.name')}>
|
||||
<Input
|
||||
{...register('name')}
|
||||
label={t('companies.name')}
|
||||
required
|
||||
error={errors.name?.message}
|
||||
placeholder="TechCorp GmbH"
|
||||
data-testid="company-name-input"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card title={t('companies.accountNumber')}>
|
||||
<Input
|
||||
{...register('account_number')}
|
||||
label={t('companies.accountNumber')}
|
||||
error={errors.account_number?.message}
|
||||
placeholder="K-00123"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('companies.industry')}>
|
||||
<Input
|
||||
{...register('industry')}
|
||||
label={t('companies.industry')}
|
||||
error={errors.industry?.message}
|
||||
placeholder="IT & Software"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('companies.phone')}>
|
||||
<Input
|
||||
{...register('phone')}
|
||||
label={t('companies.phone')}
|
||||
error={errors.phone?.message}
|
||||
placeholder="+49 30 1234567"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('companies.email')}>
|
||||
<Input
|
||||
{...register('email')}
|
||||
label={t('companies.email')}
|
||||
type="email"
|
||||
error={errors.email?.message}
|
||||
placeholder="info@techcorp.de"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title={t('companies.website')}>
|
||||
<Input
|
||||
{...register('website')}
|
||||
label={t('companies.website')}
|
||||
error={errors.website?.message}
|
||||
placeholder="https://www.techcorp.de"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title={t('companies.description')}>
|
||||
<textarea
|
||||
{...register('description')}
|
||||
className="w-full px-3 py-2 text-sm rounded-md border border-secondary-300 focus:outline-none focus:ring-2 focus:ring-primary-500 min-h-24"
|
||||
placeholder="Firmenbeschreibung..."
|
||||
aria-label={t('companies.description')}
|
||||
/>
|
||||
{errors.description && (
|
||||
<p className="mt-1 text-sm text-danger-600">{errors.description.message}</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => navigate(-1)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isSubmitting} data-testid="company-submit-btn">
|
||||
{isEdit ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useContact } from '@/api/hooks';
|
||||
import { Tabs, TabItem } from '@/components/shared/Tabs';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { TagPicker } from '@/components/tags/TagPicker';
|
||||
import { AddressList } from '@/components/AddressList';
|
||||
|
||||
export function ContactDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { data: contact, isLoading, isError } = useContact(id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="contact-detail-page">
|
||||
<Skeleton className="h-8 w-64 mb-6" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-32" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !contact) {
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="contact-detail-page">
|
||||
<EmptyState
|
||||
title={t('contacts.notFound')}
|
||||
action={<Button onClick={() => navigate('/contacts')}>{t('common.back')}</Button>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const companies = contact.companies ?? [];
|
||||
|
||||
const overviewTab: TabItem = {
|
||||
key: 'overview',
|
||||
label: t('contacts.overview'),
|
||||
content: (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card title={t('contacts.firstName')}>
|
||||
<p className="text-secondary-900">{contact.first_name}</p>
|
||||
</Card>
|
||||
<Card title={t('contacts.lastName')}>
|
||||
<p className="text-secondary-900">{contact.last_name}</p>
|
||||
</Card>
|
||||
<Card title={t('contacts.email')}>
|
||||
{contact.email ? (
|
||||
<a href={`mailto:${contact.email}`} className="text-primary-600 hover:text-primary-700">{contact.email}</a>
|
||||
) : <p className="text-secondary-500">—</p>}
|
||||
</Card>
|
||||
<Card title={t('contacts.phone')}>
|
||||
<p className="text-secondary-900">{contact.phone || '—'}</p>
|
||||
</Card>
|
||||
<Card title={t('contacts.position')}>
|
||||
<p className="text-secondary-900">{contact.position || '—'}</p>
|
||||
</Card>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
const companiesTab: TabItem = {
|
||||
key: 'companies',
|
||||
label: t('contacts.companies'),
|
||||
badge: companies.length,
|
||||
content: companies.length === 0 ? (
|
||||
<EmptyState title={t('contacts.noCompanies')} />
|
||||
) : (
|
||||
<ul className="space-y-2" role="list">
|
||||
{companies.map((company) => (
|
||||
<li key={company.id}>
|
||||
<button
|
||||
onClick={() => navigate(`/companies/${company.id}`)}
|
||||
className="w-full flex items-center gap-3 p-3 rounded-lg hover:bg-secondary-50 text-left min-h-touch"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-lg bg-primary-50 flex items-center justify-center text-primary-600 font-semibold flex-shrink-0">
|
||||
{company.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-secondary-900">{company.name}</p>
|
||||
{company.industry && (
|
||||
<p className="text-sm text-secondary-500">{company.industry}</p>
|
||||
)}
|
||||
</div>
|
||||
{company.email && (
|
||||
<Badge variant="info">{company.email}</Badge>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
),
|
||||
};
|
||||
|
||||
const addressesTab: TabItem = {
|
||||
key: 'addresses',
|
||||
label: t('address.title'),
|
||||
content: (
|
||||
<AddressList entityType="contact" entityId={contact.id} />
|
||||
),
|
||||
};
|
||||
|
||||
const tagsTab: TabItem = {
|
||||
key: 'tags',
|
||||
label: t('tags.title'),
|
||||
content: (
|
||||
<TagPicker entityType="contact" entityId={contact.id} />
|
||||
),
|
||||
};
|
||||
|
||||
const filesTab: TabItem = {
|
||||
key: 'files',
|
||||
label: t('contacts.files'),
|
||||
content: <EmptyState title={t('contacts.noFiles')} />,
|
||||
};
|
||||
|
||||
const activityTab: TabItem = {
|
||||
key: 'activity',
|
||||
label: t('contacts.activity'),
|
||||
content: <EmptyState title={t('contacts.noActivity')} />,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto" data-testid="contact-detail-page">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate('/contacts')} aria-label={t('common.back')}>
|
||||
← {t('common.back')}
|
||||
</Button>
|
||||
<Avatar name={`${contact.first_name} ${contact.last_name}`} size="md" />
|
||||
<h1 className="text-2xl font-bold text-secondary-900">{contact.first_name} {contact.last_name}</h1>
|
||||
</div>
|
||||
<Button onClick={() => navigate(`/contacts/${contact.id}/edit`)}>
|
||||
{t('common.edit')}
|
||||
</Button>
|
||||
</div>
|
||||
<Tabs tabs={[overviewTab, companiesTab, addressesTab, tagsTab, filesTab, activityTab]} defaultKey="overview" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,211 +0,0 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useContact, useCreateContact, useUpdateContact, useCompanies, Company } from '@/api/hooks';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Skeleton } from '@/components/ui/Skeleton';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { UnsavedChangesGuard } from '@/components/shared/UnsavedChangesGuard';
|
||||
|
||||
const contactSchema = z.object({
|
||||
first_name: z.string().min(1, 'Vorname ist erforderlich').max(100),
|
||||
last_name: z.string().min(1, 'Nachname ist erforderlich').max(100),
|
||||
email: z.string().email('Ungültige E-Mail-Adresse').max(255).optional().or(z.literal('')),
|
||||
phone: z.string().max(30).optional().or(z.literal('')),
|
||||
position: z.string().max(100).optional().or(z.literal('')),
|
||||
});
|
||||
|
||||
type ContactFormValues = z.infer<typeof contactSchema>;
|
||||
|
||||
export function ContactFormPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const isEdit = !!id;
|
||||
|
||||
const { data: contact, isLoading } = useContact(isEdit ? id : undefined);
|
||||
const createMutation = useCreateContact();
|
||||
const updateMutation = useUpdateContact();
|
||||
const { data: companiesData } = useCompanies(1, 100);
|
||||
const [selectedCompanyIds, setSelectedCompanyIds] = useState<string[]>([]);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors, isDirty, isSubmitting },
|
||||
} = useForm<ContactFormValues>({
|
||||
resolver: zodResolver(contactSchema),
|
||||
defaultValues: {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
position: '',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && contact) {
|
||||
reset({
|
||||
first_name: contact.first_name || '',
|
||||
last_name: contact.last_name || '',
|
||||
email: contact.email || '',
|
||||
phone: contact.phone || '',
|
||||
position: contact.position || '',
|
||||
});
|
||||
setSelectedCompanyIds(contact.company_ids || (contact.companies?.map((c) => c.id) || []));
|
||||
}
|
||||
}, [contact, isEdit, reset]);
|
||||
|
||||
const toggleCompany = (companyId: string) => {
|
||||
setSelectedCompanyIds((prev) =>
|
||||
prev.includes(companyId)
|
||||
? prev.filter((cid) => cid !== companyId)
|
||||
: [...prev, companyId]
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = async (values: ContactFormValues) => {
|
||||
const payload = { ...values, company_ids: selectedCompanyIds };
|
||||
try {
|
||||
if (isEdit && id) {
|
||||
await updateMutation.mutateAsync({ id, data: payload });
|
||||
toast.success(t('contacts.updated'));
|
||||
navigate(`/contacts/${id}`);
|
||||
} else {
|
||||
const result = await createMutation.mutateAsync(payload) as { id: string };
|
||||
toast.success(t('contacts.created'));
|
||||
navigate(`/contacts/${result.id}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed')));
|
||||
}
|
||||
};
|
||||
|
||||
if (isEdit && isLoading) {
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="contact-form-page">
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const availableCompanies: Company[] = companiesData?.items ?? [];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-4xl mx-auto" data-testid="contact-form-page">
|
||||
<UnsavedChangesGuard isDirty={isDirty || selectedCompanyIds.length > 0} />
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={() => navigate(-1)}>
|
||||
← {t('common.back')}
|
||||
</Button>
|
||||
<h1 className="text-2xl font-bold text-secondary-900">
|
||||
{isEdit ? t('contacts.edit') : t('contacts.create')}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} noValidate className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card title={t('contacts.firstName')}>
|
||||
<Input
|
||||
{...register('first_name')}
|
||||
label={t('contacts.firstName')}
|
||||
required
|
||||
error={errors.first_name?.message}
|
||||
placeholder="Max"
|
||||
data-testid="contact-first-name-input"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('contacts.lastName')}>
|
||||
<Input
|
||||
{...register('last_name')}
|
||||
label={t('contacts.lastName')}
|
||||
required
|
||||
error={errors.last_name?.message}
|
||||
placeholder="Mustermann"
|
||||
data-testid="contact-last-name-input"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('contacts.email')}>
|
||||
<Input
|
||||
{...register('email')}
|
||||
label={t('contacts.email')}
|
||||
type="email"
|
||||
error={errors.email?.message}
|
||||
placeholder="max@mustermann.de"
|
||||
/>
|
||||
</Card>
|
||||
<Card title={t('contacts.phone')}>
|
||||
<Input
|
||||
{...register('phone')}
|
||||
label={t('contacts.phone')}
|
||||
error={errors.phone?.message}
|
||||
placeholder="+49 170 1234567"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title={t('contacts.position')}>
|
||||
<Input
|
||||
{...register('position')}
|
||||
label={t('contacts.position')}
|
||||
error={errors.position?.message}
|
||||
placeholder="Geschäftsführer"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card title={t('contacts.assignCompanies')}>
|
||||
{availableCompanies.length === 0 ? (
|
||||
<p className="text-sm text-secondary-500">{t('companies.emptyTitle')}</p>
|
||||
) : (
|
||||
<div className="space-y-2" data-testid="company-assignment-list">
|
||||
{availableCompanies.map((company) => (
|
||||
<label
|
||||
key={company.id}
|
||||
className="flex items-center gap-3 p-2 rounded-md hover:bg-secondary-50 cursor-pointer min-h-touch"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedCompanyIds.includes(company.id)}
|
||||
onChange={() => toggleCompany(company.id)}
|
||||
className="w-4 h-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
|
||||
aria-label={company.name}
|
||||
/>
|
||||
<span className="text-sm text-secondary-900">{company.name}</span>
|
||||
{company.industry && (
|
||||
<Badge variant="primary">{company.industry}</Badge>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{selectedCompanyIds.length > 0 && (
|
||||
<p className="mt-2 text-xs text-secondary-500">
|
||||
{selectedCompanyIds.length} {selectedCompanyIds.length === 1 ? 'Firma zugewiesen' : 'Firmen zugewiesen'}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="secondary" onClick={() => navigate(-1)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" isLoading={isSubmitting} data-testid="contact-submit-btn">
|
||||
{isEdit ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+358
-117
@@ -1,148 +1,389 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
/**
|
||||
* Contacts page — unified 3-column layout (folder tree + list + detail).
|
||||
* Replaces old separate Contacts and Company pages.
|
||||
* Uses ResizablePanel for drag-to-resize columns, like Mail.tsx.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
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 { ResizablePanel } from '@/components/ui/ResizablePanel';
|
||||
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 { Select } from '@/components/ui/Select';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import { Avatar } from '@/components/ui/Avatar';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||
import { ContactFolderTree, type ContactFilter } from '@/components/contacts/ContactFolderTree';
|
||||
import { ContactList, type ContactViewMode } from '@/components/contacts/ContactList';
|
||||
import { ContactDetail } from '@/components/contacts/ContactDetail';
|
||||
import { ContactEditModal } from '@/components/contacts/ContactEditModal';
|
||||
import {
|
||||
useUnifiedContacts,
|
||||
useUnifiedContact,
|
||||
type UnifiedContact,
|
||||
} from '@/api/hooks';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export function ContactsListPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(25);
|
||||
const [selectedFilter, setSelectedFilter] = useState<ContactFilter>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [deleteTarget, setDeleteTarget] = useState<Contact | null>(null);
|
||||
const [page, setPage] = useState(1);
|
||||
const [sortBy, setSortBy] = useState('displayname');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
const [viewMode, setViewMode] = useState<ContactViewMode>('list');
|
||||
const [selectedContactId, setSelectedContactId] = useState<string | null>(null);
|
||||
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
|
||||
const [editModalOpen, setEditModalOpen] = useState(false);
|
||||
const [editingContact, setEditingContact] = useState<UnifiedContact | null>(null);
|
||||
|
||||
const { data, isLoading } = useContacts(page, pageSize, debouncedSearch);
|
||||
const deleteMutation = useDeleteContact();
|
||||
|
||||
React.useEffect(() => {
|
||||
// Debounce search
|
||||
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]
|
||||
// Derive type filter from selectedFilter
|
||||
const contactType = useMemo(() => {
|
||||
if (selectedFilter === 'company') return 'company';
|
||||
if (selectedFilter === 'person') return 'person';
|
||||
return undefined;
|
||||
}, [selectedFilter]);
|
||||
|
||||
// Derive tag filter
|
||||
const tagFilter = useMemo(() => {
|
||||
if (selectedFilter.startsWith('tag:')) return selectedFilter.slice(4);
|
||||
return undefined;
|
||||
}, [selectedFilter]);
|
||||
|
||||
// Fetch contacts list
|
||||
const { data, isLoading } = useUnifiedContacts(
|
||||
page,
|
||||
PAGE_SIZE,
|
||||
debouncedSearch || undefined,
|
||||
contactType,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
);
|
||||
|
||||
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'));
|
||||
}
|
||||
};
|
||||
// Fetch selected contact detail (with contact_persons)
|
||||
const { data: selectedContact, isLoading: loadingDetail } = useUnifiedContact(selectedContactId || undefined);
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
// Derive tags from all loaded contacts (for folder tree)
|
||||
const allTags = useMemo(() => {
|
||||
const tagSet = new Set<string>();
|
||||
contacts.forEach((c) => {
|
||||
if (c.tags) {
|
||||
c.tags.split(',').forEach((tag) => {
|
||||
const trimmed = tag.trim();
|
||||
if (trimmed) tagSet.add(trimmed);
|
||||
});
|
||||
}
|
||||
});
|
||||
return Array.from(tagSet).sort();
|
||||
}, [contacts]);
|
||||
|
||||
// Filter by tag client-side if tag filter is active
|
||||
const filteredContacts = useMemo(() => {
|
||||
if (!tagFilter) return contacts;
|
||||
return contacts.filter((c) => {
|
||||
if (!c.tags) return false;
|
||||
return c.tags.split(',').map((t) => t.trim()).includes(tagFilter);
|
||||
});
|
||||
}, [contacts, tagFilter]);
|
||||
|
||||
// Handle folder selection
|
||||
const handleSelectFilter = useCallback((filter: ContactFilter) => {
|
||||
setSelectedFilter(filter);
|
||||
setPage(1);
|
||||
setSelectedContactId(null);
|
||||
setActiveView('list');
|
||||
}, []);
|
||||
|
||||
// Handle contact selection
|
||||
const handleSelectContact = useCallback((contact: UnifiedContact) => {
|
||||
setSelectedContactId(contact.id);
|
||||
setActiveView('detail');
|
||||
}, []);
|
||||
|
||||
// Handle sort change
|
||||
const handleSortChange = useCallback((newSortBy: string, newSortOrder: 'asc' | 'desc') => {
|
||||
setSortBy(newSortBy);
|
||||
setSortOrder(newSortOrder);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
// Handle search
|
||||
const handleSearch = useCallback((query: string) => {
|
||||
setSearch(query);
|
||||
setPage(1);
|
||||
}, []);
|
||||
|
||||
// Handle create
|
||||
const handleCreate = useCallback(() => {
|
||||
setEditingContact(null);
|
||||
setEditModalOpen(true);
|
||||
}, []);
|
||||
|
||||
// Handle edit
|
||||
const handleEdit = useCallback(() => {
|
||||
if (selectedContact) {
|
||||
setEditingContact(selectedContact);
|
||||
setEditModalOpen(true);
|
||||
}
|
||||
}, [selectedContact]);
|
||||
|
||||
// Handle delete (from detail)
|
||||
const handleDeleted = useCallback(() => {
|
||||
setSelectedContactId(null);
|
||||
setActiveView('list');
|
||||
}, []);
|
||||
|
||||
// Handle edit modal saved
|
||||
const handleSaved = useCallback((id: string) => {
|
||||
setSelectedContactId(id);
|
||||
setActiveView('detail');
|
||||
}, []);
|
||||
|
||||
// Register toolbar items
|
||||
const registerItems = usePluginToolbarStore((s) => s.registerItems);
|
||||
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
|
||||
|
||||
useEffect(() => {
|
||||
const items = [
|
||||
{
|
||||
id: 'search',
|
||||
plugin: 'contacts',
|
||||
label: t('common.search'),
|
||||
type: 'search' as const,
|
||||
group: 'search',
|
||||
searchPlaceholder: t('contacts.searchPlaceholder'),
|
||||
onSearch: handleSearch,
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
id: 'new',
|
||||
plugin: 'contacts',
|
||||
label: t('contacts.create'),
|
||||
group: 'actions',
|
||||
icon: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
),
|
||||
onClick: handleCreate,
|
||||
},
|
||||
];
|
||||
registerItems('contacts', items);
|
||||
return () => unregisterPlugin('contacts');
|
||||
}, [handleSearch, handleCreate, t, registerItems, unregisterPlugin]);
|
||||
|
||||
// Sort options
|
||||
const sortOptions = [
|
||||
{ value: 'displayname', label: t('contacts.fullName') },
|
||||
{ value: 'name', label: t('contacts.name') },
|
||||
{ value: 'email_1', label: t('contacts.email') },
|
||||
{ value: 'mailing_city', label: t('address.city') },
|
||||
{ value: 'code', label: t('contacts.code') },
|
||||
];
|
||||
|
||||
const viewModeOptions = [
|
||||
{ value: 'list', label: t('contacts.viewList') },
|
||||
{ value: 'table', label: t('contacts.viewTable') },
|
||||
{ value: 'cards', label: t('contacts.viewCards') },
|
||||
];
|
||||
|
||||
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 className="flex flex-col h-full" data-testid="contacts-list-page">
|
||||
{/* Error display */}
|
||||
{/* (errors handled by react-query + toast) */}
|
||||
|
||||
{/* Desktop: three-pane layout with resizable panels */}
|
||||
<div className="hidden md:flex flex-1 overflow-hidden">
|
||||
{/* Left: Folder tree — resizable */}
|
||||
<ResizablePanel
|
||||
initialWidth={240}
|
||||
minWidth={150}
|
||||
maxWidth={400}
|
||||
className="border-r border-secondary-200 bg-white"
|
||||
data-testid="contact-folder-pane"
|
||||
>
|
||||
<div className="p-3">
|
||||
<ContactFolderTree
|
||||
selectedFilter={selectedFilter}
|
||||
onSelect={handleSelectFilter}
|
||||
tags={allTags}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* Middle: Contact list — resizable */}
|
||||
<ResizablePanel
|
||||
initialWidth={400}
|
||||
minWidth={250}
|
||||
maxWidth={600}
|
||||
className="border-r border-secondary-200 bg-white"
|
||||
data-testid="contact-list-pane"
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 bg-white flex-shrink-0">
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder={t('contacts.searchPlaceholder')}
|
||||
aria-label={t('common.search')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={viewMode}
|
||||
onChange={(e) => setViewMode(e.target.value as ContactViewMode)}
|
||||
options={viewModeOptions}
|
||||
aria-label={t('contacts.viewMode')}
|
||||
className="w-auto"
|
||||
/>
|
||||
<Select
|
||||
value={sortBy}
|
||||
onChange={(e) => { setSortBy(e.target.value); setPage(1); }}
|
||||
options={sortOptions}
|
||||
aria-label={t('common.filter')}
|
||||
className="w-auto"
|
||||
/>
|
||||
<Button size="sm" onClick={handleCreate} data-testid="contacts-new-btn">
|
||||
{t('contacts.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ContactList
|
||||
contacts={filteredContacts}
|
||||
selectedContactId={selectedContactId}
|
||||
onSelectContact={handleSelectContact}
|
||||
loading={isLoading}
|
||||
currentPage={page}
|
||||
total={total}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setPage}
|
||||
viewMode={viewMode}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* Right: Detail panel — flex-1, not resizable */}
|
||||
<ResizablePanel
|
||||
resizable={false}
|
||||
className="bg-white"
|
||||
data-testid="contact-detail-pane"
|
||||
>
|
||||
<ContactDetail
|
||||
contact={selectedContact ?? null}
|
||||
loading={loadingDetail}
|
||||
onEdit={handleEdit}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</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')}
|
||||
/>
|
||||
{/* Mobile: single-pane view switching */}
|
||||
<div className="flex md:hidden flex-1 overflow-hidden flex-col">
|
||||
{/* View 1: Folder tree */}
|
||||
{activeView === 'folders' && (
|
||||
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-folder-pane">
|
||||
<div className="p-3">
|
||||
<ContactFolderTree
|
||||
selectedFilter={selectedFilter}
|
||||
onSelect={handleSelectFilter}
|
||||
tags={allTags}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View 2: Contact list */}
|
||||
{activeView === 'list' && (
|
||||
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-list-pane">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
|
||||
<button
|
||||
onClick={() => setActiveView('folders')}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
||||
aria-label={t('common.back')}
|
||||
data-testid="mobile-back-to-folders"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">{t('contacts.allContacts')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-3 py-2 border-b border-secondary-200">
|
||||
<Input
|
||||
type="search"
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
placeholder={t('contacts.searchPlaceholder')}
|
||||
aria-label={t('common.search')}
|
||||
/>
|
||||
</div>
|
||||
<ContactList
|
||||
contacts={filteredContacts}
|
||||
selectedContactId={selectedContactId}
|
||||
onSelectContact={handleSelectContact}
|
||||
loading={isLoading}
|
||||
currentPage={page}
|
||||
total={total}
|
||||
pageSize={PAGE_SIZE}
|
||||
onPageChange={setPage}
|
||||
viewMode={viewMode}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View 3: Contact detail */}
|
||||
{activeView === 'detail' && (
|
||||
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-detail-pane">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
|
||||
<button
|
||||
onClick={() => setActiveView('list')}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
|
||||
aria-label={t('common.back')}
|
||||
data-testid="mobile-back-to-list"
|
||||
>
|
||||
<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="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span className="text-sm font-medium">{t('common.back')}</span>
|
||||
</button>
|
||||
</div>
|
||||
<ContactDetail
|
||||
contact={selectedContact ?? null}
|
||||
loading={loadingDetail}
|
||||
onEdit={handleEdit}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</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)}
|
||||
{/* Edit/Create Modal */}
|
||||
<ContactEditModal
|
||||
open={editModalOpen}
|
||||
onClose={() => setEditModalOpen(false)}
|
||||
contact={editingContact}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,12 +2,12 @@ import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StatCard } from '@/components/shared/StatCard';
|
||||
import { ActivityFeed, ActivityItem } from '@/components/shared/ActivityFeed';
|
||||
import { useCompanies, useContacts, useAuditLog } from '@/api/hooks';
|
||||
import { useUnifiedContacts, useAuditLog } from '@/api/hooks';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t } = useTranslation();
|
||||
const { data: companiesData } = useCompanies(1, 1);
|
||||
const { data: contactsData } = useContacts(1, 1);
|
||||
const { data: companiesData } = useUnifiedContacts(1, 1, undefined, 'company');
|
||||
const { data: contactsData } = useUnifiedContacts(1, 1, undefined, 'person');
|
||||
const { data: auditData, isError: auditError } = useAuditLog(1, 5);
|
||||
|
||||
const totalCompanies = companiesData?.total ?? 0;
|
||||
|
||||
Reference in New Issue
Block a user