feat(frontend): redesign Lexware Import with browsable list + Ansprechpartner

- Replace search-only ImportTab with paginated browsable list of all Lexware contacts
- Add expandable Ansprechpartner (contact persons) section per company
- Individual Ansprechpartner can be imported as CRM contacts
- Add pagination controls for navigating large contact lists
- Search filter still available (min. 3 chars, backend MinLength constraint)
- Clean up useLexwareContacts hook with proper query key

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-11 07:21:05 +01:00
parent 4f05026bc8
commit 55329188f6
4 changed files with 469 additions and 85 deletions

View file

@ -69,6 +69,8 @@ export const crmKeys = {
all: ['crm', 'lexware'] as const,
contactSearch: (params: LexwareContactSearchParams) =>
['crm', 'lexware', 'contacts', 'search', params] as const,
contactList: (params: LexwareContactSearchParams) =>
['crm', 'lexware', 'contacts', 'list', params] as const,
vouchersCompany: (companyId: string, params?: LexwareVouchersQueryParams) =>
['crm', 'lexware', 'vouchers', 'company', companyId, params] as const,
vouchersContact: (contactId: string, params?: LexwareVouchersQueryParams) =>
@ -405,6 +407,18 @@ export function useLexwareContactSearch(
});
}
/** Fetch Lexware contacts (paginated, no search filter required) */
export function useLexwareContacts(
params: LexwareContactSearchParams = {},
enabled = true,
) {
return useQuery({
queryKey: crmKeys.lexware.contactList(params),
queryFn: () => lexwareContactsApi.search(params),
enabled,
});
}
export function useLinkLexwareCompany() {
const qc = useQueryClient();
return useMutation({

View file

@ -127,12 +127,10 @@
margin: 0;
}
/* Result card */
/* Result card — column layout for expandable Ansprechpartner */
.resultCard {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-direction: column;
padding: 0.875rem 0;
border-bottom: 1px solid var(--color-border);
}
@ -141,6 +139,13 @@
border-bottom: none;
}
.resultCardMain {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.resultInfo {
flex: 1;
min-width: 0;
@ -436,3 +441,141 @@
opacity: 0.5;
cursor: wait;
}
/* Expand button for Ansprechpartner */
.expandBtn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.625rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text-muted);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.expandBtn:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
.expandBtnActive {
color: var(--color-primary);
border-color: var(--color-primary);
background: var(--color-primary-bg, #eff6ff);
}
/* Ansprechpartner (Contact Persons) section */
.cpSection {
margin-top: 0.75rem;
padding: 0.75rem;
background: var(--color-bg);
border-radius: var(--radius-sm);
border: 1px solid var(--color-border);
}
.cpTitle {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-muted);
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.cpRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.5rem 0;
}
.cpRow:not(:last-child) {
border-bottom: 1px solid var(--color-border);
}
.cpInfo {
flex: 1;
min-width: 0;
}
.cpName {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
}
.cpMeta {
display: block;
font-size: 0.8125rem;
color: var(--color-text-muted);
margin-top: 0.125rem;
}
.cpPrimary {
display: inline-block;
padding: 0 0.375rem;
border-radius: 4px;
background: #dbeafe;
color: #1d4ed8;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.025em;
}
:global([data-theme='dark']) .cpPrimary {
background: #1e3a5f;
color: #93c5fd;
}
/* Pagination */
.pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 1rem 0 0;
margin-top: 0.5rem;
}
.pageBtn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
}
.pageBtn:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
}
.pageBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.pageInfo {
font-size: 0.875rem;
color: var(--color-text-muted);
}

View file

@ -1,16 +1,23 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthContext';
import { useCrmSettings } from '../settings/CrmSettingsContext';
import {
useLexwareContactSearch,
useLexwareContacts,
useImportLexwareAsCompany,
useImportLexwareAsContact,
useCreateContact,
useCompanies,
useContacts,
usePushToLexware,
} from '../hooks';
import type { LexwareContact, Company, Contact } from '../types';
import type {
LexwareContact,
LexwareContactPersonEntry,
LexwareContactSearchParams,
Company,
Contact,
} from '../types';
import styles from './LexwareSyncPage.module.css';
// ============================================================
@ -59,31 +66,39 @@ function lexwareRoles(c: LexwareContact): string {
function ImportTab() {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedTerm, setDebouncedTerm] = useState('');
const [currentPage, setCurrentPage] = useState(0);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [successMessage, setSuccessMessage] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const importAsCompany = useImportLexwareAsCompany();
const importAsContact = useImportLexwareAsContact();
const createContact = useCreateContact();
useEffect(() => {
setTimeout(() => inputRef.current?.focus(), 100);
}, []);
// Debounce
// Debounce search
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedTerm(searchTerm.trim());
setCurrentPage(0);
}, 400);
return () => clearTimeout(timer);
}, [searchTerm]);
const { data, isLoading } = useLexwareContactSearch(
{ name: debouncedTerm },
debouncedTerm.length >= 2,
);
// Build params: name only when >= 3 chars (backend MinLength(3))
const searchParams: LexwareContactSearchParams = {
page: currentPage,
size: 25,
...(debouncedTerm.length >= 3 ? { name: debouncedTerm } : {}),
};
const { data, isLoading } = useLexwareContacts(searchParams);
const results: LexwareContact[] = data?.data ?? [];
const isImporting = importAsCompany.isPending || importAsContact.isPending;
const pagination = data?.pagination;
const totalPages = pagination?.totalPages ?? 1;
const isImporting =
importAsCompany.isPending ||
importAsContact.isPending ||
createContact.isPending;
const handleImportAsCompany = (lexwareContactId: string, name: string) => {
importAsCompany.mutate(
@ -109,18 +124,54 @@ function ImportTab() {
);
};
const handleImportContactPerson = (
person: LexwareContactPersonEntry,
companyName: string,
) => {
const cpName =
[person.firstName, person.lastName].filter(Boolean).join(' ') ||
'Ansprechpartner';
createContact.mutate(
{
firstName: person.firstName || undefined,
lastName: person.lastName || undefined,
email: person.emailAddress || undefined,
phone: person.phoneNumber || undefined,
companyName: companyName || undefined,
type: 'PERSON',
},
{
onSuccess: () => {
setSuccessMessage(
`Ansprechpartner "${cpName}" als Kontakt importiert`,
);
setTimeout(() => setSuccessMessage(''), 4000);
},
},
);
};
const toggleExpanded = (id: string) => {
setExpandedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<div>
<p className={styles.tabDesc}>
Lexware Office Kontakte durchsuchen und als Unternehmen oder Kontakt in
das CRM importieren.
Alle Lexware Office Kontakte durchsuchen und als Unternehmen oder
Kontakt ins CRM importieren. Ansprechpartner können einzeln importiert
werden.
</p>
<input
ref={inputRef}
type="text"
className={styles.searchInput}
placeholder="Name oder Firma in Lexware suchen..."
placeholder="Kontakte filtern (min. 3 Zeichen)..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
@ -143,25 +194,30 @@ function ImportTab() {
</div>
)}
{(importAsCompany.isError || importAsContact.isError) && (
{(importAsCompany.isError ||
importAsContact.isError ||
createContact.isError) && (
<div className={styles.errorBanner}>
Import fehlgeschlagen:{' '}
{(importAsCompany.error ?? importAsContact.error)?.message ??
'Unbekannter Fehler'}
{(
importAsCompany.error ??
importAsContact.error ??
createContact.error
)?.message ?? 'Unbekannter Fehler'}
</div>
)}
<div className={styles.resultsList}>
{debouncedTerm.length < 2 && (
<p className={styles.hint}>Mindestens 2 Zeichen eingeben...</p>
{isLoading && (
<p className={styles.hint}>Lade Lexware Office Kontakte...</p>
)}
{debouncedTerm.length >= 2 && isLoading && (
<p className={styles.hint}>Suche in Lexware Office...</p>
)}
{debouncedTerm.length >= 2 && !isLoading && results.length === 0 && (
<p className={styles.hint}>Keine Ergebnisse gefunden</p>
{!isLoading && results.length === 0 && (
<p className={styles.hint}>
{debouncedTerm.length >= 3
? 'Keine Ergebnisse für diese Suche'
: 'Keine Kontakte in Lexware Office gefunden'}
</p>
)}
{results.map((contact) => {
@ -169,67 +225,220 @@ function ImportTab() {
const email = lexwareEmail(contact);
const address = lexwareAddress(contact);
const roles = lexwareRoles(contact);
const contactPersons = contact.company?.contactPersons ?? [];
const isExpanded = expandedIds.has(contact.id);
return (
<div key={contact.id} className={styles.resultCard}>
<div className={styles.resultInfo}>
<div className={styles.resultName}>{name}</div>
<div className={styles.resultMeta}>
{roles !== '—' && (
<span className={styles.roleBadge}>{roles}</span>
{/* Main row */}
<div className={styles.resultCardMain}>
<div className={styles.resultInfo}>
<div className={styles.resultName}>{name}</div>
<div className={styles.resultMeta}>
{roles !== '—' && (
<span className={styles.roleBadge}>{roles}</span>
)}
{email && <span>{email}</span>}
{address && <span>{address}</span>}
</div>
</div>
<div className={styles.resultActions}>
{contactPersons.length > 0 && (
<button
className={`${styles.expandBtn} ${isExpanded ? styles.expandBtnActive : ''}`}
onClick={() => toggleExpanded(contact.id)}
title={
isExpanded
? 'Ansprechpartner ausblenden'
: 'Ansprechpartner anzeigen'
}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
style={{
transform: isExpanded
? 'rotate(180deg)'
: 'none',
transition: 'transform 0.2s',
}}
>
<path d="M4 6l4 4 4-4" />
</svg>
{contactPersons.length} AP
</button>
)}
{email && <span>{email}</span>}
{address && <span>{address}</span>}
<button
className={styles.importBtn}
disabled={isImporting}
onClick={() => handleImportAsCompany(contact.id, name)}
title="Als Unternehmen importieren"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="6" width="12" height="9" rx="1" />
<path d="M5 6V3a1 1 0 011-1h4a1 1 0 011 1v3" />
</svg>
Unternehmen
</button>
<button
className={styles.importBtn}
disabled={isImporting}
onClick={() => handleImportAsContact(contact.id, name)}
title="Als Kontakt importieren"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="5" r="3" />
<path d="M2 14c0-2.5 2.5-4.5 6-4.5s6 2 6 4.5" />
</svg>
Kontakt
</button>
</div>
</div>
<div className={styles.resultActions}>
<button
className={styles.importBtn}
disabled={isImporting}
onClick={() => handleImportAsCompany(contact.id, name)}
title="Als Unternehmen importieren"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="2" y="6" width="12" height="9" rx="1" />
<path d="M5 6V3a1 1 0 011-1h4a1 1 0 011 1v3" />
</svg>
Unternehmen
</button>
<button
className={styles.importBtn}
disabled={isImporting}
onClick={() => handleImportAsContact(contact.id, name)}
title="Als Kontakt importieren"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="5" r="3" />
<path d="M2 14c0-2.5 2.5-4.5 6-4.5s6 2 6 4.5" />
</svg>
Kontakt
</button>
</div>
{/* Ansprechpartner expandable section */}
{isExpanded && contactPersons.length > 0 && (
<div className={styles.cpSection}>
<div className={styles.cpTitle}>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="6" cy="5" r="2.5" />
<path d="M1 13c0-2 2-3.5 5-3.5" />
<circle cx="12" cy="6" r="2" />
<path d="M9 13c0-1.5 1.5-2.5 3-2.5s3 1 3 2.5" />
</svg>
Ansprechpartner ({contactPersons.length})
</div>
{contactPersons.map((cp, idx) => {
const cpName =
[cp.firstName, cp.lastName]
.filter(Boolean)
.join(' ') || 'Unbenannt';
return (
<div key={idx} className={styles.cpRow}>
<div className={styles.cpInfo}>
<span className={styles.cpName}>
{cpName}
{cp.primary && (
<span className={styles.cpPrimary}>Haupt</span>
)}
</span>
<span className={styles.cpMeta}>
{[cp.emailAddress, cp.phoneNumber]
.filter(Boolean)
.join(' · ') || 'Keine Kontaktdaten'}
</span>
</div>
<button
className={styles.importBtn}
disabled={isImporting}
onClick={() =>
handleImportContactPerson(
cp,
contact.company?.name ?? '',
)
}
title={`${cpName} als CRM-Kontakt importieren`}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="8" cy="5" r="3" />
<path d="M2 14c0-2.5 2.5-4.5 6-4.5s6 2 6 4.5" />
</svg>
Als Kontakt
</button>
</div>
);
})}
</div>
)}
</div>
);
})}
</div>
{/* Pagination */}
{pagination && totalPages > 1 && (
<div className={styles.pagination}>
<button
className={styles.pageBtn}
disabled={currentPage === 0}
onClick={() => setCurrentPage((p) => p - 1)}
>
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M9 2L4 7l5 5" />
</svg>
Zurück
</button>
<span className={styles.pageInfo}>
Seite {currentPage + 1} von {totalPages}
{pagination.total != null && ` (${pagination.total} Kontakte)`}
</span>
<button
className={styles.pageBtn}
disabled={currentPage + 1 >= totalPages}
onClick={() => setCurrentPage((p) => p + 1)}
>
Weiter
<svg
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
>
<path d="M5 2l5 5-5 5" />
</svg>
</button>
</div>
)}
</div>
);
}

View file

@ -359,11 +359,25 @@ export interface DealVoucher {
voucher: LexwareVoucher;
}
export interface LexwareContactPersonEntry {
salutation?: string;
firstName?: string;
lastName?: string;
primary?: boolean;
emailAddress?: string;
phoneNumber?: string;
}
export interface LexwareContact {
id: string;
version: number;
roles: { customer?: Record<string, unknown>; vendor?: Record<string, unknown> };
company?: { name?: string; taxId?: string; allowTaxFreeInvoices?: boolean };
company?: {
name?: string;
taxId?: string;
allowTaxFreeInvoices?: boolean;
contactPersons?: LexwareContactPersonEntry[];
};
person?: { firstName?: string; lastName?: string; salutation?: string };
addresses?: {
billing?: { street?: string; zip?: string; city?: string; countryCode?: string }[];
@ -378,6 +392,10 @@ export interface LexwareContact {
export interface LexwareContactSearchParams {
name?: string;
email?: string;
customer?: boolean;
vendor?: boolean;
page?: number;
size?: number;
}
export interface LexwareVouchersQueryParams {