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, all: ['crm', 'lexware'] as const,
contactSearch: (params: LexwareContactSearchParams) => contactSearch: (params: LexwareContactSearchParams) =>
['crm', 'lexware', 'contacts', 'search', params] as const, ['crm', 'lexware', 'contacts', 'search', params] as const,
contactList: (params: LexwareContactSearchParams) =>
['crm', 'lexware', 'contacts', 'list', params] as const,
vouchersCompany: (companyId: string, params?: LexwareVouchersQueryParams) => vouchersCompany: (companyId: string, params?: LexwareVouchersQueryParams) =>
['crm', 'lexware', 'vouchers', 'company', companyId, params] as const, ['crm', 'lexware', 'vouchers', 'company', companyId, params] as const,
vouchersContact: (contactId: string, params?: LexwareVouchersQueryParams) => 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() { export function useLinkLexwareCompany() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({

View file

@ -127,12 +127,10 @@
margin: 0; margin: 0;
} }
/* Result card */ /* Result card — column layout for expandable Ansprechpartner */
.resultCard { .resultCard {
display: flex; display: flex;
align-items: center; flex-direction: column;
justify-content: space-between;
gap: 1rem;
padding: 0.875rem 0; padding: 0.875rem 0;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
} }
@ -141,6 +139,13 @@
border-bottom: none; border-bottom: none;
} }
.resultCardMain {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.resultInfo { .resultInfo {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
@ -436,3 +441,141 @@
opacity: 0.5; opacity: 0.5;
cursor: wait; 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 { Link, Navigate } from 'react-router-dom';
import { useAuth } from '../../auth/AuthContext'; import { useAuth } from '../../auth/AuthContext';
import { useCrmSettings } from '../settings/CrmSettingsContext'; import { useCrmSettings } from '../settings/CrmSettingsContext';
import { import {
useLexwareContactSearch, useLexwareContacts,
useImportLexwareAsCompany, useImportLexwareAsCompany,
useImportLexwareAsContact, useImportLexwareAsContact,
useCreateContact,
useCompanies, useCompanies,
useContacts, useContacts,
usePushToLexware, usePushToLexware,
} from '../hooks'; } from '../hooks';
import type { LexwareContact, Company, Contact } from '../types'; import type {
LexwareContact,
LexwareContactPersonEntry,
LexwareContactSearchParams,
Company,
Contact,
} from '../types';
import styles from './LexwareSyncPage.module.css'; import styles from './LexwareSyncPage.module.css';
// ============================================================ // ============================================================
@ -59,31 +66,39 @@ function lexwareRoles(c: LexwareContact): string {
function ImportTab() { function ImportTab() {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [debouncedTerm, setDebouncedTerm] = useState(''); const [debouncedTerm, setDebouncedTerm] = useState('');
const [currentPage, setCurrentPage] = useState(0);
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
const [successMessage, setSuccessMessage] = useState(''); const [successMessage, setSuccessMessage] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const importAsCompany = useImportLexwareAsCompany(); const importAsCompany = useImportLexwareAsCompany();
const importAsContact = useImportLexwareAsContact(); const importAsContact = useImportLexwareAsContact();
const createContact = useCreateContact();
useEffect(() => { // Debounce search
setTimeout(() => inputRef.current?.focus(), 100);
}, []);
// Debounce
useEffect(() => { useEffect(() => {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setDebouncedTerm(searchTerm.trim()); setDebouncedTerm(searchTerm.trim());
setCurrentPage(0);
}, 400); }, 400);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [searchTerm]); }, [searchTerm]);
const { data, isLoading } = useLexwareContactSearch( // Build params: name only when >= 3 chars (backend MinLength(3))
{ name: debouncedTerm }, const searchParams: LexwareContactSearchParams = {
debouncedTerm.length >= 2, page: currentPage,
); size: 25,
...(debouncedTerm.length >= 3 ? { name: debouncedTerm } : {}),
};
const { data, isLoading } = useLexwareContacts(searchParams);
const results: LexwareContact[] = data?.data ?? []; 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) => { const handleImportAsCompany = (lexwareContactId: string, name: string) => {
importAsCompany.mutate( 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 ( return (
<div> <div>
<p className={styles.tabDesc}> <p className={styles.tabDesc}>
Lexware Office Kontakte durchsuchen und als Unternehmen oder Kontakt in Alle Lexware Office Kontakte durchsuchen und als Unternehmen oder
das CRM importieren. Kontakt ins CRM importieren. Ansprechpartner können einzeln importiert
werden.
</p> </p>
<input <input
ref={inputRef}
type="text" type="text"
className={styles.searchInput} className={styles.searchInput}
placeholder="Name oder Firma in Lexware suchen..." placeholder="Kontakte filtern (min. 3 Zeichen)..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
/> />
@ -143,25 +194,30 @@ function ImportTab() {
</div> </div>
)} )}
{(importAsCompany.isError || importAsContact.isError) && ( {(importAsCompany.isError ||
importAsContact.isError ||
createContact.isError) && (
<div className={styles.errorBanner}> <div className={styles.errorBanner}>
Import fehlgeschlagen:{' '} Import fehlgeschlagen:{' '}
{(importAsCompany.error ?? importAsContact.error)?.message ?? {(
'Unbekannter Fehler'} importAsCompany.error ??
importAsContact.error ??
createContact.error
)?.message ?? 'Unbekannter Fehler'}
</div> </div>
)} )}
<div className={styles.resultsList}> <div className={styles.resultsList}>
{debouncedTerm.length < 2 && ( {isLoading && (
<p className={styles.hint}>Mindestens 2 Zeichen eingeben...</p> <p className={styles.hint}>Lade Lexware Office Kontakte...</p>
)} )}
{debouncedTerm.length >= 2 && isLoading && ( {!isLoading && results.length === 0 && (
<p className={styles.hint}>Suche in Lexware Office...</p> <p className={styles.hint}>
)} {debouncedTerm.length >= 3
? 'Keine Ergebnisse für diese Suche'
{debouncedTerm.length >= 2 && !isLoading && results.length === 0 && ( : 'Keine Kontakte in Lexware Office gefunden'}
<p className={styles.hint}>Keine Ergebnisse gefunden</p> </p>
)} )}
{results.map((contact) => { {results.map((contact) => {
@ -169,67 +225,220 @@ function ImportTab() {
const email = lexwareEmail(contact); const email = lexwareEmail(contact);
const address = lexwareAddress(contact); const address = lexwareAddress(contact);
const roles = lexwareRoles(contact); const roles = lexwareRoles(contact);
const contactPersons = contact.company?.contactPersons ?? [];
const isExpanded = expandedIds.has(contact.id);
return ( return (
<div key={contact.id} className={styles.resultCard}> <div key={contact.id} className={styles.resultCard}>
<div className={styles.resultInfo}> {/* Main row */}
<div className={styles.resultName}>{name}</div> <div className={styles.resultCardMain}>
<div className={styles.resultMeta}> <div className={styles.resultInfo}>
{roles !== '—' && ( <div className={styles.resultName}>{name}</div>
<span className={styles.roleBadge}>{roles}</span> <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>} <button
{address && <span>{address}</span>} 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> </div>
<div className={styles.resultActions}>
<button {/* Ansprechpartner expandable section */}
className={styles.importBtn} {isExpanded && contactPersons.length > 0 && (
disabled={isImporting} <div className={styles.cpSection}>
onClick={() => handleImportAsCompany(contact.id, name)} <div className={styles.cpTitle}>
title="Als Unternehmen importieren" <svg
> width="14"
<svg height="14"
width="14" viewBox="0 0 16 16"
height="14" fill="none"
viewBox="0 0 16 16" stroke="currentColor"
fill="none" strokeWidth="1.5"
stroke="currentColor" strokeLinecap="round"
strokeWidth="1.5" strokeLinejoin="round"
strokeLinecap="round" >
strokeLinejoin="round" <circle cx="6" cy="5" r="2.5" />
> <path d="M1 13c0-2 2-3.5 5-3.5" />
<rect x="2" y="6" width="12" height="9" rx="1" /> <circle cx="12" cy="6" r="2" />
<path d="M5 6V3a1 1 0 011-1h4a1 1 0 011 1v3" /> <path d="M9 13c0-1.5 1.5-2.5 3-2.5s3 1 3 2.5" />
</svg> </svg>
Unternehmen Ansprechpartner ({contactPersons.length})
</button> </div>
<button {contactPersons.map((cp, idx) => {
className={styles.importBtn} const cpName =
disabled={isImporting} [cp.firstName, cp.lastName]
onClick={() => handleImportAsContact(contact.id, name)} .filter(Boolean)
title="Als Kontakt importieren" .join(' ') || 'Unbenannt';
> return (
<svg <div key={idx} className={styles.cpRow}>
width="14" <div className={styles.cpInfo}>
height="14" <span className={styles.cpName}>
viewBox="0 0 16 16" {cpName}
fill="none" {cp.primary && (
stroke="currentColor" <span className={styles.cpPrimary}>Haupt</span>
strokeWidth="1.5" )}
strokeLinecap="round" </span>
strokeLinejoin="round" <span className={styles.cpMeta}>
> {[cp.emailAddress, cp.phoneNumber]
<circle cx="8" cy="5" r="3" /> .filter(Boolean)
<path d="M2 14c0-2.5 2.5-4.5 6-4.5s6 2 6 4.5" /> .join(' · ') || 'Keine Kontaktdaten'}
</svg> </span>
Kontakt </div>
</button> <button
</div> 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>
); );
})} })}
</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> </div>
); );
} }

View file

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