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:
Agent Zero
2026-07-19 21:30:26 +02:00
parent cf75680583
commit 3177daf47f
25 changed files with 1869 additions and 1592 deletions
@@ -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>
);
}