From 55329188f61559e55fa586d270689e4611a1946c Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Wed, 11 Mar 2026 07:21:05 +0100 Subject: [PATCH] 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 --- packages/frontend/src/crm/hooks.ts | 14 + .../crm/lexware/LexwareSyncPage.module.css | 151 ++++++- .../src/crm/lexware/LexwareSyncPage.tsx | 369 ++++++++++++++---- packages/frontend/src/crm/types.ts | 20 +- 4 files changed, 469 insertions(+), 85 deletions(-) diff --git a/packages/frontend/src/crm/hooks.ts b/packages/frontend/src/crm/hooks.ts index 510da03..2bc47f3 100644 --- a/packages/frontend/src/crm/hooks.ts +++ b/packages/frontend/src/crm/hooks.ts @@ -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({ diff --git a/packages/frontend/src/crm/lexware/LexwareSyncPage.module.css b/packages/frontend/src/crm/lexware/LexwareSyncPage.module.css index 531c630..9882fd3 100644 --- a/packages/frontend/src/crm/lexware/LexwareSyncPage.module.css +++ b/packages/frontend/src/crm/lexware/LexwareSyncPage.module.css @@ -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); +} diff --git a/packages/frontend/src/crm/lexware/LexwareSyncPage.tsx b/packages/frontend/src/crm/lexware/LexwareSyncPage.tsx index 32b71d0..c8f9478 100644 --- a/packages/frontend/src/crm/lexware/LexwareSyncPage.tsx +++ b/packages/frontend/src/crm/lexware/LexwareSyncPage.tsx @@ -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>(new Set()); const [successMessage, setSuccessMessage] = useState(''); - const inputRef = useRef(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 (

- 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.

setSearchTerm(e.target.value)} /> @@ -143,25 +194,30 @@ function ImportTab() {
)} - {(importAsCompany.isError || importAsContact.isError) && ( + {(importAsCompany.isError || + importAsContact.isError || + createContact.isError) && (
Import fehlgeschlagen:{' '} - {(importAsCompany.error ?? importAsContact.error)?.message ?? - 'Unbekannter Fehler'} + {( + importAsCompany.error ?? + importAsContact.error ?? + createContact.error + )?.message ?? 'Unbekannter Fehler'}
)}
- {debouncedTerm.length < 2 && ( -

Mindestens 2 Zeichen eingeben...

+ {isLoading && ( +

Lade Lexware Office Kontakte...

)} - {debouncedTerm.length >= 2 && isLoading && ( -

Suche in Lexware Office...

- )} - - {debouncedTerm.length >= 2 && !isLoading && results.length === 0 && ( -

Keine Ergebnisse gefunden

+ {!isLoading && results.length === 0 && ( +

+ {debouncedTerm.length >= 3 + ? 'Keine Ergebnisse für diese Suche' + : 'Keine Kontakte in Lexware Office gefunden'} +

)} {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 (
-
-
{name}
-
- {roles !== '—' && ( - {roles} + {/* Main row */} +
+
+
{name}
+
+ {roles !== '—' && ( + {roles} + )} + {email && {email}} + {address && {address}} +
+
+
+ {contactPersons.length > 0 && ( + )} - {email && {email}} - {address && {address}} + +
-
- - -
+ + {/* Ansprechpartner expandable section */} + {isExpanded && contactPersons.length > 0 && ( +
+
+ + + + + + + Ansprechpartner ({contactPersons.length}) +
+ {contactPersons.map((cp, idx) => { + const cpName = + [cp.firstName, cp.lastName] + .filter(Boolean) + .join(' ') || 'Unbenannt'; + return ( +
+
+ + {cpName} + {cp.primary && ( + Haupt + )} + + + {[cp.emailAddress, cp.phoneNumber] + .filter(Boolean) + .join(' · ') || 'Keine Kontaktdaten'} + +
+ +
+ ); + })} +
+ )}
); })}
+ + {/* Pagination */} + {pagination && totalPages > 1 && ( +
+ + + Seite {currentPage + 1} von {totalPages} + {pagination.total != null && ` (${pagination.total} Kontakte)`} + + +
+ )}
); } diff --git a/packages/frontend/src/crm/types.ts b/packages/frontend/src/crm/types.ts index 029bf7f..ccaeee5 100644 --- a/packages/frontend/src/crm/types.ts +++ b/packages/frontend/src/crm/types.ts @@ -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; vendor?: Record }; - 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 {