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:
@@ -0,0 +1,372 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
type UnifiedContact,
|
||||
type ContactPerson,
|
||||
useDeleteUnifiedContact,
|
||||
useCreateContactPerson,
|
||||
useUpdateContactPerson,
|
||||
useDeleteContactPerson,
|
||||
} from '@/api/hooks';
|
||||
|
||||
export interface ContactDetailProps {
|
||||
contact: UnifiedContact | null;
|
||||
loading?: boolean;
|
||||
onEdit: () => void;
|
||||
onDeleted: () => void;
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-secondary-500">{label}</dt>
|
||||
<dd className="text-sm text-secondary-900">{value || '—'}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="border border-secondary-200 rounded-lg p-4 mb-3">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-secondary-700">{title}</h3>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AddressBlock({ prefix, contact, t }: { prefix: string; contact: UnifiedContact; t: (k: string) => string }) {
|
||||
const street = (contact as any)[`${prefix}_street`] as string | undefined;
|
||||
const number = (contact as any)[`${prefix}_number`] as string | undefined;
|
||||
const postalcode = (contact as any)[`${prefix}_postalcode`] as string | undefined;
|
||||
const city = (contact as any)[`${prefix}_city`] as string | undefined;
|
||||
const state = (contact as any)[`${prefix}_state`] as string | undefined;
|
||||
const country = (contact as any)[`${prefix}_country`] as string | undefined;
|
||||
const district = (contact as any)[`${prefix}_district`] as string | undefined;
|
||||
const extra = (contact as any)[`${prefix}_extra_address_line`] as string | undefined;
|
||||
|
||||
const hasData = street || city || postalcode;
|
||||
if (!hasData) return null;
|
||||
|
||||
return (
|
||||
<div className="text-sm text-secondary-900 space-y-0.5">
|
||||
{extra && <div>{extra}</div>}
|
||||
<div>{street} {number}</div>
|
||||
{district && <div>{district}</div>}
|
||||
<div>{postalcode} {city}</div>
|
||||
{state && <div>{state}</div>}
|
||||
{country && <div>{country}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactPersonModal({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
person,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (data: Partial<ContactPerson>) => void;
|
||||
person?: ContactPerson | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [firstname, setFirstname] = useState(person?.firstname || '');
|
||||
const [lastname, setLastname] = useState(person?.lastname || '');
|
||||
const [func, setFunc] = useState(person?.function || '');
|
||||
const [email, setEmail] = useState(person?.email || '');
|
||||
const [phone, setPhone] = useState(person?.phone || '');
|
||||
const [mobilephone, setMobilephone] = useState(person?.mobilephone || '');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setFirstname(person?.firstname || '');
|
||||
setLastname(person?.lastname || '');
|
||||
setFunc(person?.function || '');
|
||||
setEmail(person?.email || '');
|
||||
setPhone(person?.phone || '');
|
||||
setMobilephone(person?.mobilephone || '');
|
||||
}
|
||||
}, [open, person]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({
|
||||
firstname: firstname || null,
|
||||
lastname: lastname || null,
|
||||
function: func || null,
|
||||
email: email || null,
|
||||
phone: phone || null,
|
||||
mobilephone: mobilephone || null,
|
||||
displayname: [firstname, lastname].filter(Boolean).join(' ') || firstname || lastname || '',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={person ? t('contacts.editPerson') : t('contacts.addPerson')} size="md">
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label={t('contacts.firstName')} value={firstname} onChange={(e) => setFirstname(e.target.value)} />
|
||||
<Input label={t('contacts.lastName')} value={lastname} onChange={(e) => setLastname(e.target.value)} />
|
||||
</div>
|
||||
<Input label={t('contacts.position')} value={func} onChange={(e) => setFunc(e.target.value)} />
|
||||
<Input label={t('contacts.email')} type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label={t('contacts.phone')} value={phone} onChange={(e) => setPhone(e.target.value)} />
|
||||
<Input label={t('contacts.mobile')} value={mobilephone} onChange={(e) => setMobilephone(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} data-testid="person-save-btn">{t('common.save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactDetail({ contact, loading, onEdit, onDeleted }: ContactDetailProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const deleteMutation = useDeleteUnifiedContact();
|
||||
const createPersonMutation = useCreateContactPerson();
|
||||
const updatePersonMutation = useUpdateContactPerson();
|
||||
const deletePersonMutation = useDeleteContactPerson();
|
||||
const [personModalOpen, setPersonModalOpen] = useState(false);
|
||||
const [editingPerson, setEditingPerson] = useState<ContactPerson | null>(null);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12" data-testid="contact-detail-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 (!contact) {
|
||||
return (
|
||||
<div data-testid="contact-detail-empty">
|
||||
<EmptyState title={t('contacts.selectContact')} description={t('contacts.selectContactDesc')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const displayName = contact.displayname || contact.name || [contact.firstname, contact.surname].filter(Boolean).join(' ') || contact.id;
|
||||
const persons = contact.contact_persons || [];
|
||||
const tags = contact.tags ? contact.tags.split(',').map((t) => t.trim()).filter(Boolean) : [];
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm(t('contacts.deleteConfirm'))) return;
|
||||
try {
|
||||
await deleteMutation.mutateAsync({ id: contact.id });
|
||||
toast.success(t('contacts.deleted'));
|
||||
onDeleted();
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSavePerson = async (data: Partial<ContactPerson>) => {
|
||||
try {
|
||||
if (editingPerson) {
|
||||
await updatePersonMutation.mutateAsync({ contactId: contact.id, personId: editingPerson.id, data });
|
||||
toast.success(t('contacts.personUpdated'));
|
||||
} else {
|
||||
await createPersonMutation.mutateAsync({ contactId: contact.id, data });
|
||||
toast.success(t('contacts.personCreated'));
|
||||
}
|
||||
setPersonModalOpen(false);
|
||||
setEditingPerson(null);
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeletePerson = async (person: ContactPerson) => {
|
||||
if (!window.confirm(t('contacts.deletePersonConfirm'))) return;
|
||||
try {
|
||||
await deletePersonMutation.mutateAsync({ contactId: contact.id, personId: person.id });
|
||||
toast.success(t('contacts.personDeleted'));
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || t('common.error'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-y-auto h-full" data-testid="contact-detail">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-secondary-200 sticky top-0 bg-white z-10">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center text-primary-700 font-semibold flex-shrink-0">
|
||||
{displayName.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-lg font-semibold text-secondary-900 truncate">{displayName}</h2>
|
||||
<Badge variant={contact.type === 'company' ? 'primary' : 'info'}>
|
||||
{contact.type === 'company' ? t('contacts.companies') : t('contacts.persons')}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Button variant="ghost" size="sm" onClick={onEdit}>{t('common.edit')}</Button>
|
||||
<Button variant="ghost" size="sm" onClick={handleDelete} isLoading={deleteMutation.isPending}>{t('common.delete')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{/* Contact Persons */}
|
||||
<Section
|
||||
title={t('contacts.contactPersons')}
|
||||
action={
|
||||
<Button size="sm" variant="ghost" onClick={() => { setEditingPerson(null); setPersonModalOpen(true); }}>
|
||||
{t('contacts.addPerson')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{persons.length === 0 ? (
|
||||
<p className="text-sm text-secondary-500">{t('contacts.noPersons')}</p>
|
||||
) : (
|
||||
<ul className="space-y-2" role="list">
|
||||
{persons.map((person) => (
|
||||
<li key={person.id} className="flex items-center gap-3 p-2 rounded-md hover:bg-secondary-50">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-secondary-900">
|
||||
{[person.firstname, person.lastname].filter(Boolean).join(' ') || person.displayname}
|
||||
</div>
|
||||
{person.function && <div className="text-xs text-secondary-500">{person.function}</div>}
|
||||
{person.email && <div className="text-xs text-secondary-500">{person.email}</div>}
|
||||
{person.phone && <div className="text-xs text-secondary-500">{person.phone}</div>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setEditingPerson(person); setPersonModalOpen(true); }}
|
||||
className="text-xs text-primary-600 hover:text-primary-700 px-2 py-1 min-h-touch"
|
||||
>
|
||||
{t('common.edit')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeletePerson(person)}
|
||||
className="text-xs text-danger-600 hover:text-danger-700 px-2 py-1 min-h-touch"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Addresses */}
|
||||
<Section title={t('address.title')}>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-secondary-500 mb-1">{t('contacts.mailingAddress')}</h4>
|
||||
<AddressBlock prefix="mailing" contact={contact} t={t} />
|
||||
{!contact.mailing_street && !contact.mailing_city && <p className="text-sm text-secondary-400">—</p>}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-secondary-500 mb-1">{t('contacts.visitAddress')}</h4>
|
||||
<AddressBlock prefix="visit" contact={contact} t={t} />
|
||||
{!contact.visit_street && !contact.visit_city && <p className="text-sm text-secondary-400">—</p>}
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-secondary-500 mb-1">{t('contacts.invoiceAddress')}</h4>
|
||||
<AddressBlock prefix="invoice" contact={contact} t={t} />
|
||||
{!contact.invoice_street && !contact.invoice_city && <p className="text-sm text-secondary-400">—</p>}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Communication */}
|
||||
<Section title={t('contacts.communication')}>
|
||||
<dl className="grid grid-cols-2 gap-3">
|
||||
<Field label={t('contacts.phone') + ' 1'} value={contact.phone_1} />
|
||||
<Field label={t('contacts.phone') + ' 2'} value={contact.phone_2} />
|
||||
<Field label={t('contacts.email') + ' 1'} value={contact.email_1} />
|
||||
<Field label={t('contacts.email') + ' 2'} value={contact.email_2} />
|
||||
<Field label={t('contacts.website')} value={contact.website} />
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
{/* Financial */}
|
||||
<Section title={t('contacts.financial')}>
|
||||
<dl className="grid grid-cols-2 gap-3">
|
||||
<Field label={t('contacts.vatCode')} value={contact.vat_code} />
|
||||
<Field label={t('contacts.fiscalCode')} value={contact.fiscal_code} />
|
||||
<Field label={t('contacts.commerceCode')} value={contact.commerce_code} />
|
||||
<Field label={t('contacts.purchaseNumber')} value={contact.purchase_number} />
|
||||
<Field label={t('contacts.bic')} value={contact.bic} />
|
||||
<Field label={t('contacts.bankAccount')} value={contact.bank_account} />
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
{/* Discounts */}
|
||||
<Section title={t('contacts.discounts')}>
|
||||
<dl className="grid grid-cols-3 gap-3">
|
||||
<Field label={t('contacts.discountCrew')} value={contact.discount_crew ? String(contact.discount_crew) : '0'} />
|
||||
<Field label={t('contacts.discountTransport')} value={contact.discount_transport ? String(contact.discount_transport) : '0'} />
|
||||
<Field label={t('contacts.discountRental')} value={contact.discount_rental ? String(contact.discount_rental) : '0'} />
|
||||
<Field label={t('contacts.discountSale')} value={contact.discount_sale ? String(contact.discount_sale) : '0'} />
|
||||
<Field label={t('contacts.discountSubrent')} value={contact.discount_subrent ? String(contact.discount_subrent) : '0'} />
|
||||
<Field label={t('contacts.discountTotal')} value={contact.discount_total ? String(contact.discount_total) : '0'} />
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
{/* Geo */}
|
||||
<Section title={t('contacts.geo')}>
|
||||
<dl className="grid grid-cols-2 gap-3">
|
||||
<Field label={t('contacts.latitude')} value={contact.latitude != null ? String(contact.latitude) : null} />
|
||||
<Field label={t('contacts.longitude')} value={contact.longitude != null ? String(contact.longitude) : null} />
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
{/* Notes & Tags */}
|
||||
<Section title={t('contacts.notes')}>
|
||||
<dl className="space-y-3">
|
||||
<Field label={t('contacts.projectnote')} value={contact.projectnote} />
|
||||
<Field label={t('contacts.projectnoteTitle')} value={contact.projectnote_title} />
|
||||
<Field label={t('contacts.contactWarning')} value={contact.contact_warning} />
|
||||
<div>
|
||||
<dt className="text-xs font-medium text-secondary-500">{t('tags.title')}</dt>
|
||||
<dd>
|
||||
{tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1 mt-1">
|
||||
{tags.map((tag) => <Badge key={tag} variant="secondary">{tag}</Badge>)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-secondary-400">—</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Section>
|
||||
|
||||
{/* Custom Fields */}
|
||||
{contact.custom && Object.keys(contact.custom).length > 0 && (
|
||||
<Section title={t('contacts.customFields')}>
|
||||
<pre className="text-xs text-secondary-700 bg-secondary-50 rounded p-2 overflow-x-auto">
|
||||
{JSON.stringify(contact.custom, null, 2)}
|
||||
</pre>
|
||||
</Section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ContactPersonModal
|
||||
open={personModalOpen}
|
||||
onClose={() => { setPersonModalOpen(false); setEditingPerson(null); }}
|
||||
onSave={handleSavePerson}
|
||||
person={editingPerson}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Modal } from '@/components/ui/Modal';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { useToast } from '@/components/ui/Toast';
|
||||
import {
|
||||
type UnifiedContact,
|
||||
useCreateUnifiedContact,
|
||||
useUpdateUnifiedContact,
|
||||
} from '@/api/hooks';
|
||||
|
||||
export interface ContactEditModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
contact?: UnifiedContact | null;
|
||||
onSaved?: (id: string) => void;
|
||||
}
|
||||
|
||||
export function ContactEditModal({ open, onClose, contact, onSaved }: ContactEditModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const toast = useToast();
|
||||
const createMutation = useCreateUnifiedContact();
|
||||
const updateMutation = useUpdateUnifiedContact();
|
||||
const isEdit = !!contact;
|
||||
|
||||
const [type, setType] = useState<'company' | 'person'>('company');
|
||||
const [name, setName] = useState('');
|
||||
const [firstname, setFirstname] = useState('');
|
||||
const [surname, setSurname] = useState('');
|
||||
const [code, setCode] = useState('');
|
||||
const [email1, setEmail1] = useState('');
|
||||
const [email2, setEmail2] = useState('');
|
||||
const [phone1, setPhone1] = useState('');
|
||||
const [phone2, setPhone2] = useState('');
|
||||
const [website, setWebsite] = useState('');
|
||||
const [mailingStreet, setMailingStreet] = useState('');
|
||||
const [mailingNumber, setMailingNumber] = useState('');
|
||||
const [mailingPostalcode, setMailingPostalcode] = useState('');
|
||||
const [mailingCity, setMailingCity] = useState('');
|
||||
const [mailingCountry, setMailingCountry] = useState('');
|
||||
const [vatCode, setVatCode] = useState('');
|
||||
const [fiscalCode, setFiscalCode] = useState('');
|
||||
const [commerceCode, setCommerceCode] = useState('');
|
||||
const [bic, setBic] = useState('');
|
||||
const [bankAccount, setBankAccount] = useState('');
|
||||
const [tags, setTags] = useState('');
|
||||
const [projectnote, setProjectnote] = useState('');
|
||||
const [contactWarning, setContactWarning] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setType((contact?.type as 'company' | 'person') || 'company');
|
||||
setName(contact?.name || '');
|
||||
setFirstname(contact?.firstname || '');
|
||||
setSurname(contact?.surname || '');
|
||||
setCode(contact?.code || '');
|
||||
setEmail1(contact?.email_1 || '');
|
||||
setEmail2(contact?.email_2 || '');
|
||||
setPhone1(contact?.phone_1 || '');
|
||||
setPhone2(contact?.phone_2 || '');
|
||||
setWebsite(contact?.website || '');
|
||||
setMailingStreet(contact?.mailing_street || '');
|
||||
setMailingNumber(contact?.mailing_number || '');
|
||||
setMailingPostalcode(contact?.mailing_postalcode || '');
|
||||
setMailingCity(contact?.mailing_city || '');
|
||||
setMailingCountry(contact?.mailing_country || '');
|
||||
setVatCode(contact?.vat_code || '');
|
||||
setFiscalCode(contact?.fiscal_code || '');
|
||||
setCommerceCode(contact?.commerce_code || '');
|
||||
setBic(contact?.bic || '');
|
||||
setBankAccount(contact?.bank_account || '');
|
||||
setTags(contact?.tags || '');
|
||||
setProjectnote(contact?.projectnote || '');
|
||||
setContactWarning(contact?.contact_warning || '');
|
||||
}
|
||||
}, [open, contact]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const data: Partial<UnifiedContact> = {
|
||||
type,
|
||||
name: type === 'company' ? name : null,
|
||||
firstname: type === 'person' ? firstname : null,
|
||||
surname: type === 'person' ? surname : null,
|
||||
code: code || null,
|
||||
email_1: email1 || null,
|
||||
email_2: email2 || null,
|
||||
phone_1: phone1 || null,
|
||||
phone_2: phone2 || null,
|
||||
website: website || null,
|
||||
mailing_street: mailingStreet || null,
|
||||
mailing_number: mailingNumber || null,
|
||||
mailing_postalcode: mailingPostalcode || null,
|
||||
mailing_city: mailingCity || null,
|
||||
mailing_country: mailingCountry || null,
|
||||
vat_code: vatCode || null,
|
||||
fiscal_code: fiscalCode || null,
|
||||
commerce_code: commerceCode || null,
|
||||
bic: bic || null,
|
||||
bank_account: bankAccount || null,
|
||||
tags: tags || null,
|
||||
projectnote: projectnote || null,
|
||||
contact_warning: contactWarning || null,
|
||||
};
|
||||
|
||||
try {
|
||||
if (isEdit && contact) {
|
||||
await updateMutation.mutateAsync({ id: contact.id, data });
|
||||
toast.success(t('contacts.updated'));
|
||||
onSaved?.(contact.id);
|
||||
} else {
|
||||
const result = await createMutation.mutateAsync(data) as { id: string };
|
||||
toast.success(t('contacts.created'));
|
||||
onSaved?.(result.id);
|
||||
}
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
toast.error(err.message || (isEdit ? t('contacts.updateFailed') : t('contacts.createFailed')));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={onClose} title={isEdit ? t('contacts.edit') : t('contacts.create')} size="xl" fullScreenMobile>
|
||||
<div className="space-y-4">
|
||||
{/* Type */}
|
||||
<Select
|
||||
label={t('contacts.type')}
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as 'company' | 'person')}
|
||||
options={[
|
||||
{ value: 'company', label: t('contacts.companies') },
|
||||
{ value: 'person', label: t('contacts.persons') },
|
||||
]}
|
||||
data-testid="contact-type-select"
|
||||
/>
|
||||
|
||||
{/* Name fields */}
|
||||
{type === 'company' ? (
|
||||
<Input label={t('contacts.name')} value={name} onChange={(e) => setName(e.target.value)} required data-testid="contact-name-input" placeholder="TechCorp GmbH" />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label={t('contacts.firstName')} value={firstname} onChange={(e) => setFirstname(e.target.value)} data-testid="contact-first-name-input" />
|
||||
<Input label={t('contacts.lastName')} value={surname} onChange={(e) => setSurname(e.target.value)} data-testid="contact-last-name-input" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code */}
|
||||
<Input label={t('contacts.code')} value={code} onChange={(e) => setCode(e.target.value)} placeholder="K-00123" />
|
||||
|
||||
{/* Communication */}
|
||||
<div className="border border-secondary-200 rounded-lg p-3">
|
||||
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.communication')}</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label={t('contacts.email') + ' 1'} type="email" value={email1} onChange={(e) => setEmail1(e.target.value)} />
|
||||
<Input label={t('contacts.email') + ' 2'} type="email" value={email2} onChange={(e) => setEmail2(e.target.value)} />
|
||||
<Input label={t('contacts.phone') + ' 1'} value={phone1} onChange={(e) => setPhone1(e.target.value)} />
|
||||
<Input label={t('contacts.phone') + ' 2'} value={phone2} onChange={(e) => setPhone2(e.target.value)} />
|
||||
<Input label={t('contacts.website')} value={website} onChange={(e) => setWebsite(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mailing Address */}
|
||||
<div className="border border-secondary-200 rounded-lg p-3">
|
||||
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.mailingAddress')}</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label={t('address.street')} value={mailingStreet} onChange={(e) => setMailingStreet(e.target.value)} />
|
||||
<Input label={t('address.streetNumber')} value={mailingNumber} onChange={(e) => setMailingNumber(e.target.value)} />
|
||||
<Input label={t('address.zip')} value={mailingPostalcode} onChange={(e) => setMailingPostalcode(e.target.value)} />
|
||||
<Input label={t('address.city')} value={mailingCity} onChange={(e) => setMailingCity(e.target.value)} />
|
||||
<Input label={t('address.country')} value={mailingCountry} onChange={(e) => setMailingCountry(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Financial */}
|
||||
<div className="border border-secondary-200 rounded-lg p-3">
|
||||
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.financial')}</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input label={t('contacts.vatCode')} value={vatCode} onChange={(e) => setVatCode(e.target.value)} />
|
||||
<Input label={t('contacts.fiscalCode')} value={fiscalCode} onChange={(e) => setFiscalCode(e.target.value)} />
|
||||
<Input label={t('contacts.commerceCode')} value={commerceCode} onChange={(e) => setCommerceCode(e.target.value)} />
|
||||
<Input label={t('contacts.bic')} value={bic} onChange={(e) => setBic(e.target.value)} />
|
||||
<Input label={t('contacts.bankAccount')} value={bankAccount} onChange={(e) => setBankAccount(e.target.value)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="border border-secondary-200 rounded-lg p-3">
|
||||
<h3 className="text-sm font-semibold text-secondary-700 mb-2">{t('contacts.notes')}</h3>
|
||||
<div className="space-y-3">
|
||||
<Input label={t('contacts.tags')} value={tags} onChange={(e) => setTags(e.target.value)} placeholder="tag1, tag2" />
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.projectnote')}</label>
|
||||
<textarea
|
||||
value={projectnote}
|
||||
onChange={(e) => setProjectnote(e.target.value)}
|
||||
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-20"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('contacts.contactWarning')}</label>
|
||||
<textarea
|
||||
value={contactWarning}
|
||||
onChange={(e) => setContactWarning(e.target.value)}
|
||||
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-20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="secondary" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} isLoading={createMutation.isPending || updateMutation.isPending} data-testid="contact-submit-btn">
|
||||
{isEdit ? t('common.save') : t('common.create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type ContactFilter = 'all' | 'company' | 'person' | `tag:${string}`;
|
||||
|
||||
export interface ContactFolderTreeProps {
|
||||
selectedFilter: ContactFilter;
|
||||
onSelect: (filter: ContactFilter) => void;
|
||||
tags: string[];
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const folderIcon = (path: string) => (
|
||||
<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={path} />
|
||||
</svg>
|
||||
);
|
||||
|
||||
export function ContactFolderTree({
|
||||
selectedFilter,
|
||||
onSelect,
|
||||
tags,
|
||||
loading,
|
||||
}: ContactFolderTreeProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const folders: { key: ContactFilter; label: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
key: 'all',
|
||||
label: t('contacts.allContacts'),
|
||||
icon: folderIcon('M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z'),
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
label: t('contacts.companies'),
|
||||
icon: folderIcon('M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4'),
|
||||
},
|
||||
{
|
||||
key: 'person',
|
||||
label: t('contacts.persons'),
|
||||
icon: folderIcon('M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<nav className="space-y-1" aria-label={t('contacts.title')} data-testid="contact-folder-tree">
|
||||
{folders.map((folder) => (
|
||||
<button
|
||||
key={folder.key}
|
||||
onClick={() => onSelect(folder.key)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm font-medium min-h-touch text-left',
|
||||
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
selectedFilter === folder.key
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-secondary-700 hover:bg-secondary-100',
|
||||
)}
|
||||
aria-current={selectedFilter === folder.key ? 'true' : undefined}
|
||||
>
|
||||
{folder.icon}
|
||||
<span>{folder.label}</span>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{tags.length > 0 && (
|
||||
<>
|
||||
<div className="my-2 border-t border-secondary-200" />
|
||||
<div className="px-2 py-1 text-xs font-semibold text-secondary-500 uppercase tracking-wide">
|
||||
{t('tags.title')}
|
||||
</div>
|
||||
{tags.map((tag) => {
|
||||
const key = `tag:${tag}` as ContactFilter;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
onClick={() => onSelect(key)}
|
||||
className={clsx(
|
||||
'flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm font-medium min-h-touch text-left',
|
||||
'transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
selectedFilter === key
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-secondary-700 hover:bg-secondary-100',
|
||||
)}
|
||||
aria-current={selectedFilter === key ? 'true' : undefined}
|
||||
>
|
||||
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5a1.99 1.99 0 01.832.184l4 2A2 2 0 0118 7v10a2 2 0 01-2 2H7a2 2 0 01-2-2V5a2 2 0 012-2z" />
|
||||
</svg>
|
||||
<span className="truncate">{tag}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className="px-2 py-1 text-xs text-secondary-400">{t('common.loading')}</div>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
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
|
||||
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}
|
||||
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}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -18,7 +18,6 @@ const navIcon = (path: string) => (
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ to: '/dashboard', labelKey: 'nav.dashboard', icon: navIcon('M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6') },
|
||||
{ to: '/companies', labelKey: 'nav.companies', icon: navIcon('M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4') },
|
||||
{ to: '/contacts', labelKey: 'nav.contacts', icon: navIcon('M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6-3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z') },
|
||||
{ to: '/calendar', labelKey: 'nav.calendar', icon: navIcon('M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z') },
|
||||
{ to: '/dms', labelKey: 'nav.files', icon: navIcon('M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z') },
|
||||
|
||||
Reference in New Issue
Block a user