feat(frontend): add Lexware Office integration UI

Implements complete Lexware Office frontend integration:
- Types: LexwareVoucher, DealVoucher, LexwareContact, VoucherType
- API: lexwareContactsApi + lexwareVouchersApi (16 endpoints)
- Hooks: React Query hooks for search, link/unlink, sync, vouchers
- LexwareSection: reusable component for Company/Contact detail pages
  with status badge, sync/push/refresh buttons, and voucher table
- LexwareSearchModal: search Lexware contacts and link to CRM entities
- DealVouchersSection: link/unlink vouchers on Deal detail page
- CRM Settings: Lexware Office toggle (admin-configurable)
- Company/Contact/Deal detail pages extended with Lexware sections

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-10 22:20:18 +01:00
parent 9d496d2e53
commit 2381409e6d
12 changed files with 1671 additions and 4 deletions

View file

@ -26,6 +26,11 @@ import type {
CreateCompanyPayload,
UpdateCompanyPayload,
CompaniesQueryParams,
LexwareContact,
LexwareContactSearchParams,
LexwareVoucher,
LexwareVouchersQueryParams,
DealVoucher,
PaginatedResponse,
SingleResponse,
} from './types';
@ -199,3 +204,124 @@ export const companiesApi = {
.delete<SingleResponse<Company>>(`/crm/companies/${id}`)
.then((r) => r.data),
};
// --- Lexware Office: Contacts ---
export const lexwareContactsApi = {
search: (params: LexwareContactSearchParams) =>
api
.get<PaginatedResponse<LexwareContact>>('/crm/lexware/contacts/search', {
params,
})
.then((r) => r.data),
linkCompany: (data: { lexwareContactId: string; companyId: string }) =>
api
.post<SingleResponse<Company>>('/crm/lexware/contacts/link-company', data)
.then((r) => r.data),
linkContact: (data: { lexwareContactId: string; contactId: string }) =>
api
.post<SingleResponse<Contact>>('/crm/lexware/contacts/link-contact', data)
.then((r) => r.data),
unlinkCompany: (companyId: string) =>
api
.delete<SingleResponse<Company>>(
`/crm/lexware/contacts/unlink-company/${companyId}`,
)
.then((r) => r.data),
unlinkContact: (contactId: string) =>
api
.delete<SingleResponse<Contact>>(
`/crm/lexware/contacts/unlink-contact/${contactId}`,
)
.then((r) => r.data),
importCompany: (data: { lexwareContactId: string }) =>
api
.post<SingleResponse<Company>>(
'/crm/lexware/contacts/import-company',
data,
)
.then((r) => r.data),
importContact: (data: { lexwareContactId: string }) =>
api
.post<SingleResponse<Contact>>(
'/crm/lexware/contacts/import-contact',
data,
)
.then((r) => r.data),
push: (entityType: 'company' | 'contact', entityId: string) =>
api
.post<SingleResponse<Company | Contact>>(
`/crm/lexware/contacts/push/${entityType}/${entityId}`,
)
.then((r) => r.data),
sync: (entityType: 'company' | 'contact', entityId: string) =>
api
.post<SingleResponse<Company | Contact>>(
`/crm/lexware/contacts/sync/${entityType}/${entityId}`,
)
.then((r) => r.data),
};
// --- Lexware Office: Vouchers ---
export const lexwareVouchersApi = {
getForCompany: (companyId: string, params?: LexwareVouchersQueryParams) =>
api
.get<PaginatedResponse<LexwareVoucher>>(
`/crm/lexware/vouchers/company/${companyId}`,
{ params },
)
.then((r) => r.data),
getForContact: (contactId: string, params?: LexwareVouchersQueryParams) =>
api
.get<PaginatedResponse<LexwareVoucher>>(
`/crm/lexware/vouchers/contact/${contactId}`,
{ params },
)
.then((r) => r.data),
getForDeal: (dealId: string) =>
api
.get<{ success: boolean; data: DealVoucher[]; meta: { timestamp: string } }>(
`/crm/lexware/vouchers/deal/${dealId}`,
)
.then((r) => r.data),
linkToDeal: (dealId: string, voucherId: string) =>
api
.post<SingleResponse<DealVoucher>>(
`/crm/lexware/vouchers/deal/${dealId}/link`,
{ voucherId },
)
.then((r) => r.data),
unlinkFromDeal: (dealId: string, voucherId: string) =>
api
.delete<SingleResponse<DealVoucher>>(
`/crm/lexware/vouchers/deal/${dealId}/unlink/${voucherId}`,
)
.then((r) => r.data),
refreshCompany: (companyId: string) =>
api
.post<SingleResponse<{ count: number }>>(
`/crm/lexware/vouchers/refresh/company/${companyId}`,
)
.then((r) => r.data),
refreshContact: (contactId: string) =>
api
.post<SingleResponse<{ count: number }>>(
`/crm/lexware/vouchers/refresh/contact/${contactId}`,
)
.then((r) => r.data),
};

View file

@ -3,6 +3,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
import { useCompany, useDeleteCompany } from '../hooks';
import { CompanyFormModal } from './CompanyFormModal';
import { Modal } from '../../components/Modal';
import { LexwareSection } from '../lexware/LexwareSection';
import type { DealStatus } from '../types';
import styles from './CompanyDetailPage.module.css';
@ -226,7 +227,7 @@ export function CompanyDetailPage() {
</div>
</div>
{/* Rechts: Kontakte + Vorgänge */}
{/* Rechts: Kontakte + Vorgänge + Lexware */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.5rem' }}>
{/* Kontakte */}
<div className={styles.card}>
@ -456,6 +457,14 @@ export function CompanyDetailPage() {
</table>
)}
</div>
{/* Lexware Office */}
<LexwareSection
entityType="company"
entityId={company.id}
lexwareContactId={company.lexwareContactId ?? null}
lexwareSyncedAt={company.lexwareSyncedAt ?? null}
/>
</div>
</div>

View file

@ -4,6 +4,7 @@ import { useContact, useDeals, useDeleteContact } from '../hooks';
import { ContactFormModal } from './ContactFormModal';
import { ActivityFormModal } from '../activities/ActivityFormModal';
import { Modal } from '../../components/Modal';
import { LexwareSection } from '../lexware/LexwareSection';
import type { Contact, Activity, ActivityType, ContactType } from '../types';
import styles from './ContactDetailPage.module.css';
@ -440,6 +441,16 @@ export function ContactDetailPage() {
</table>
</div>
)}
{/* Lexware Office */}
<div style={{ marginTop: '1.5rem' }}>
<LexwareSection
entityType="contact"
entityId={contact.id}
lexwareContactId={contact.lexwareContactId ?? null}
lexwareSyncedAt={contact.lexwareSyncedAt ?? null}
/>
</div>
</div>
{/* Rechts: Aktivitäten-Timeline */}

View file

@ -3,6 +3,7 @@ import { useParams, Link, useNavigate } from 'react-router-dom';
import { useDeal, useDeleteDeal } from '../hooks';
import { DealFormModal } from './DealFormModal';
import { Modal } from '../../components/Modal';
import { DealVouchersSection } from '../lexware/DealVouchersSection';
import type { DealStatus } from '../types';
import styles from './DealDetailPage.module.css';
@ -247,6 +248,13 @@ export function DealDetailPage() {
)}
</div>
{/* Belege (Lexware Vouchers) */}
<DealVouchersSection
dealId={deal.id}
companyId={deal.companyId ?? deal.company?.id ?? null}
contactId={deal.contactId ?? deal.contact?.id ?? null}
/>
{/* Modals */}
<DealFormModal
isOpen={isEditOpen}

View file

@ -3,7 +3,15 @@
// ============================================================
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { contactsApi, dealsApi, pipelinesApi, activitiesApi, companiesApi } from './api';
import {
contactsApi,
dealsApi,
pipelinesApi,
activitiesApi,
companiesApi,
lexwareContactsApi,
lexwareVouchersApi,
} from './api';
import type {
ContactsQueryParams,
DealsQueryParams,
@ -21,6 +29,8 @@ import type {
CompaniesQueryParams,
CreateCompanyPayload,
UpdateCompanyPayload,
LexwareContactSearchParams,
LexwareVouchersQueryParams,
} from './types';
// --- Query Key Factory ---
@ -55,6 +65,17 @@ export const crmKeys = {
['crm', 'companies', 'list', params] as const,
detail: (id: string) => ['crm', 'companies', 'detail', id] as const,
},
lexware: {
all: ['crm', 'lexware'] as const,
contactSearch: (params: LexwareContactSearchParams) =>
['crm', 'lexware', 'contacts', 'search', params] as const,
vouchersCompany: (companyId: string, params?: LexwareVouchersQueryParams) =>
['crm', 'lexware', 'vouchers', 'company', companyId, params] as const,
vouchersContact: (contactId: string, params?: LexwareVouchersQueryParams) =>
['crm', 'lexware', 'vouchers', 'contact', contactId, params] as const,
vouchersDeal: (dealId: string) =>
['crm', 'lexware', 'vouchers', 'deal', dealId] as const,
},
};
// ============================================================
@ -368,3 +389,180 @@ export function useDeleteCompany() {
},
});
}
// ============================================================
// Lexware Office — Contacts
// ============================================================
export function useLexwareContactSearch(
params: LexwareContactSearchParams,
enabled = true,
) {
return useQuery({
queryKey: crmKeys.lexware.contactSearch(params),
queryFn: () => lexwareContactsApi.search(params),
enabled: enabled && !!(params.name || params.email),
});
}
export function useLinkLexwareCompany() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { lexwareContactId: string; companyId: string }) =>
lexwareContactsApi.linkCompany(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
},
});
}
export function useLinkLexwareContact() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { lexwareContactId: string; contactId: string }) =>
lexwareContactsApi.linkContact(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
},
});
}
export function useUnlinkLexwareCompany() {
const qc = useQueryClient();
return useMutation({
mutationFn: (companyId: string) =>
lexwareContactsApi.unlinkCompany(companyId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
},
});
}
export function useUnlinkLexwareContact() {
const qc = useQueryClient();
return useMutation({
mutationFn: (contactId: string) =>
lexwareContactsApi.unlinkContact(contactId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
},
});
}
export function usePushToLexware() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
entityType,
entityId,
}: {
entityType: 'company' | 'contact';
entityId: string;
}) => lexwareContactsApi.push(entityType, entityId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
},
});
}
export function useSyncFromLexware() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
entityType,
entityId,
}: {
entityType: 'company' | 'contact';
entityId: string;
}) => lexwareContactsApi.sync(entityType, entityId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.companies.all });
qc.invalidateQueries({ queryKey: crmKeys.contacts.all });
},
});
}
// ============================================================
// Lexware Office — Vouchers
// ============================================================
export function useCompanyVouchers(
companyId: string,
params?: LexwareVouchersQueryParams,
) {
return useQuery({
queryKey: crmKeys.lexware.vouchersCompany(companyId, params),
queryFn: () => lexwareVouchersApi.getForCompany(companyId, params),
enabled: !!companyId,
});
}
export function useContactVouchers(
contactId: string,
params?: LexwareVouchersQueryParams,
) {
return useQuery({
queryKey: crmKeys.lexware.vouchersContact(contactId, params),
queryFn: () => lexwareVouchersApi.getForContact(contactId, params),
enabled: !!contactId,
});
}
export function useDealVouchers(dealId: string) {
return useQuery({
queryKey: crmKeys.lexware.vouchersDeal(dealId),
queryFn: () => lexwareVouchersApi.getForDeal(dealId),
enabled: !!dealId,
});
}
export function useLinkVoucherToDeal() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ dealId, voucherId }: { dealId: string; voucherId: string }) =>
lexwareVouchersApi.linkToDeal(dealId, voucherId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
},
});
}
export function useUnlinkVoucherFromDeal() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ dealId, voucherId }: { dealId: string; voucherId: string }) =>
lexwareVouchersApi.unlinkFromDeal(dealId, voucherId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
qc.invalidateQueries({ queryKey: crmKeys.deals.all });
},
});
}
export function useRefreshCompanyVouchers() {
const qc = useQueryClient();
return useMutation({
mutationFn: (companyId: string) =>
lexwareVouchersApi.refreshCompany(companyId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
},
});
}
export function useRefreshContactVouchers() {
const qc = useQueryClient();
return useMutation({
mutationFn: (contactId: string) =>
lexwareVouchersApi.refreshContact(contactId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: crmKeys.lexware.all });
},
});
}

View file

@ -0,0 +1,324 @@
import { useState } from 'react';
import {
useDealVouchers,
useLinkVoucherToDeal,
useUnlinkVoucherFromDeal,
useCompanyVouchers,
useContactVouchers,
} from '../hooks';
import { useCrmSettings } from '../settings/CrmSettingsContext';
import type { LexwareVoucher, DealVoucher, VoucherType } from '../types';
import { VOUCHER_TYPE_LABELS } from '../types';
import { Modal } from '../../components/Modal';
import styles from './LexwareSection.module.css';
// ============================================================
// Helpers
// ============================================================
const currencyFormatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
});
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function voucherTypeClass(type: VoucherType): string {
switch (type) {
case 'QUOTATION':
return styles.typeQuotation;
case 'ORDER_CONFIRMATION':
return styles.typeOrderConfirmation;
case 'INVOICE':
return styles.typeInvoice;
case 'CREDIT_NOTE':
return styles.typeCreditNote;
default:
return '';
}
}
function voucherStatusClass(status: string | null): string {
if (!status) return '';
const lower = status.toLowerCase();
if (lower === 'paid' || lower === 'bezahlt') return styles.statusPaid;
if (lower === 'overdue' || lower === 'überfällig') return styles.statusOverdue;
if (lower === 'open' || lower === 'offen') return styles.statusOpen;
return '';
}
// ============================================================
// LinkVoucherModal — pick from available vouchers
// ============================================================
interface LinkVoucherModalProps {
isOpen: boolean;
onClose: () => void;
dealId: string;
companyId: string | null;
contactId: string | null;
linkedVoucherIds: Set<string>;
}
function LinkVoucherModal({
isOpen,
onClose,
dealId,
companyId,
contactId,
linkedVoucherIds,
}: LinkVoucherModalProps) {
const linkMutation = useLinkVoucherToDeal();
// Fetch available vouchers from company or contact
const companyVouchers = useCompanyVouchers(
companyId ?? '',
companyId && isOpen ? { pageSize: 100 } : undefined,
);
const contactVouchers = useContactVouchers(
contactId ?? '',
contactId && !companyId && isOpen ? { pageSize: 100 } : undefined,
);
const sourceData = companyId ? companyVouchers : contactVouchers;
const allVouchers: LexwareVoucher[] = sourceData.data?.data ?? [];
// Filter out already linked vouchers
const available = allVouchers.filter((v) => !linkedVoucherIds.has(v.id));
const handleLink = (voucherId: string) => {
linkMutation.mutate(
{ dealId, voucherId },
{ onSuccess: () => onClose() },
);
};
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Beleg verknüpfen"
maxWidth="560px"
>
{!companyId && !contactId && (
<p className={styles.emptyText}>
Dieser Vorgang hat kein verknüpftes Unternehmen oder Kontakt mit
Lexware-Verbindung. Bitte zuerst einen Lexware-Kontakt verknüpfen.
</p>
)}
{(companyId || contactId) && sourceData.isLoading && (
<p className={styles.loadingText}>Belege werden geladen...</p>
)}
{(companyId || contactId) && !sourceData.isLoading && available.length === 0 && (
<p className={styles.emptyText}>
Keine weiteren Belege zum Verknüpfen verfügbar
</p>
)}
{available.length > 0 && (
<div className={styles.searchResults}>
{available.map((v) => (
<div key={v.id} className={styles.searchResultItem}>
<div className={styles.searchResultInfo}>
<div className={styles.searchResultName}>
<span
className={`${styles.voucherTypeBadge} ${voucherTypeClass(v.voucherType)}`}
style={{ marginRight: '0.5rem' }}
>
{VOUCHER_TYPE_LABELS[v.voucherType]}
</span>
{v.voucherNumber ?? 'Ohne Nummer'}
</div>
<div className={styles.searchResultMeta}>
{v.voucherDate ? formatDate(v.voucherDate) : '—'}
{v.totalGrossAmount && (
<>
{' · '}
{currencyFormatter.format(
parseFloat(v.totalGrossAmount),
)}
</>
)}
{v.title && <> · {v.title}</>}
</div>
</div>
<button
className={styles.searchResultBtn}
disabled={linkMutation.isPending}
onClick={() => handleLink(v.id)}
>
{linkMutation.isPending ? '...' : 'Verknüpfen'}
</button>
</div>
))}
</div>
)}
</Modal>
);
}
// ============================================================
// DealVouchersSection
// ============================================================
interface DealVouchersSectionProps {
dealId: string;
companyId: string | null;
contactId: string | null;
}
export function DealVouchersSection({
dealId,
companyId,
contactId,
}: DealVouchersSectionProps) {
const { isModuleEnabled } = useCrmSettings();
const [isLinkOpen, setLinkOpen] = useState(false);
// Hide entire section if Lexware module is disabled
if (!isModuleEnabled('lexware')) return null;
const { data, isLoading } = useDealVouchers(dealId);
const unlinkMutation = useUnlinkVoucherFromDeal();
const dealVouchers: DealVoucher[] = data?.data ?? [];
const linkedVoucherIds = new Set(dealVouchers.map((dv) => dv.voucher.id));
return (
<div className={styles.card}>
{/* Header */}
<div className={styles.cardHeader}>
<h2 className={styles.cardTitle}>
<span className={styles.lexwareLogo}>LX</span>
Belege ({dealVouchers.length})
</h2>
<button
className={styles.actionBtnPrimary}
onClick={() => setLinkOpen(true)}
style={{ padding: '0.25rem 0.625rem', fontSize: '0.8125rem' }}
>
+ Beleg verknüpfen
</button>
</div>
{isLoading ? (
<p className={styles.loadingText}>Belege werden geladen...</p>
) : dealVouchers.length === 0 ? (
<p className={styles.emptyText}>
Keine Belege mit diesem Vorgang verknüpft
</p>
) : (
<table className={styles.voucherTable}>
<thead>
<tr>
<th>Typ</th>
<th>Nummer</th>
<th>Datum</th>
<th>Status</th>
<th>Betrag</th>
<th style={{ width: 36 }} />
</tr>
</thead>
<tbody>
{dealVouchers.map((dv) => {
const v = dv.voucher;
return (
<tr key={dv.id}>
<td>
<span
className={`${styles.voucherTypeBadge} ${voucherTypeClass(v.voucherType)}`}
>
{VOUCHER_TYPE_LABELS[v.voucherType]}
</span>
</td>
<td>
{v.lexwareDeepLink ? (
<a
href={v.lexwareDeepLink}
target="_blank"
rel="noopener noreferrer"
className={styles.voucherLink}
style={{ fontSize: '0.8125rem' }}
>
{v.voucherNumber ?? '—'}
</a>
) : (
v.voucherNumber ?? '—'
)}
</td>
<td>
{v.voucherDate ? formatDate(v.voucherDate) : '—'}
</td>
<td>
<span className={voucherStatusClass(v.voucherStatus)}>
{v.voucherStatus ?? '—'}
</span>
</td>
<td>
{v.totalGrossAmount
? currencyFormatter.format(
parseFloat(v.totalGrossAmount),
)
: '—'}
</td>
<td>
<button
onClick={() =>
unlinkMutation.mutate({
dealId,
voucherId: v.id,
})
}
disabled={unlinkMutation.isPending}
title="Verknüpfung aufheben"
style={{
background: 'none',
border: 'none',
cursor: unlinkMutation.isPending
? 'wait'
: 'pointer',
color: 'var(--color-text-muted)',
padding: '0.25rem',
display: 'flex',
alignItems: 'center',
}}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{/* Link Voucher Modal */}
<LinkVoucherModal
isOpen={isLinkOpen}
onClose={() => setLinkOpen(false)}
dealId={dealId}
companyId={companyId}
contactId={contactId}
linkedVoucherIds={linkedVoucherIds}
/>
</div>
);
}

View file

@ -0,0 +1,147 @@
import { useState, useEffect, useRef } from 'react';
import { Modal } from '../../components/Modal';
import { useLexwareContactSearch } from '../hooks';
import type { LexwareContact } from '../types';
import styles from './LexwareSection.module.css';
interface LexwareSearchModalProps {
isOpen: boolean;
onClose: () => void;
onLink: (lexwareContactId: string) => void;
isLinking: boolean;
}
/**
* Extracts a display name from a LexwareContact.
*/
function lexwareDisplayName(c: LexwareContact): string {
const companyName = c.company?.name;
const personName = [c.person?.firstName, c.person?.lastName]
.filter(Boolean)
.join(' ');
if (companyName && personName) return `${companyName} (${personName})`;
return companyName || personName || 'Unbenannt';
}
/**
* Extracts the primary email from a LexwareContact.
*/
function lexwareEmail(c: LexwareContact): string | null {
const emails = c.emailAddresses;
if (!emails) return null;
return (
emails.business?.[0] ??
emails.office?.[0] ??
emails.private?.[0] ??
emails.other?.[0] ??
null
);
}
/**
* Extracts the primary address as a single-line string.
*/
function lexwareAddress(c: LexwareContact): string | null {
const addr = c.addresses?.billing?.[0] ?? c.addresses?.shipping?.[0];
if (!addr) return null;
const parts = [addr.street, [addr.zip, addr.city].filter(Boolean).join(' ')];
return parts.filter(Boolean).join(', ') || null;
}
export function LexwareSearchModal({
isOpen,
onClose,
onLink,
isLinking,
}: LexwareSearchModalProps) {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedTerm, setDebouncedTerm] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
// Reset search when modal opens
useEffect(() => {
if (isOpen) {
setSearchTerm('');
setDebouncedTerm('');
setTimeout(() => inputRef.current?.focus(), 100);
}
}, [isOpen]);
// Debounce search input
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedTerm(searchTerm.trim());
}, 400);
return () => clearTimeout(timer);
}, [searchTerm]);
const { data, isLoading } = useLexwareContactSearch(
{ name: debouncedTerm },
isOpen && debouncedTerm.length >= 2,
);
const results: LexwareContact[] = data?.data ?? [];
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Lexware Kontakt suchen & verknüpfen"
maxWidth="560px"
>
<input
ref={inputRef}
type="text"
className={styles.searchInput}
placeholder="Name oder Firma eingeben..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
<div className={styles.searchResults}>
{debouncedTerm.length < 2 && (
<p className={styles.loadingText}>
Mindestens 2 Zeichen eingeben...
</p>
)}
{debouncedTerm.length >= 2 && isLoading && (
<p className={styles.loadingText}>Suche in Lexware...</p>
)}
{debouncedTerm.length >= 2 && !isLoading && results.length === 0 && (
<p className={styles.loadingText}>
Keine Ergebnisse gefunden
</p>
)}
{results.map((contact) => {
const email = lexwareEmail(contact);
const address = lexwareAddress(contact);
return (
<div key={contact.id} className={styles.searchResultItem}>
<div className={styles.searchResultInfo}>
<div className={styles.searchResultName}>
{lexwareDisplayName(contact)}
</div>
<div className={styles.searchResultMeta}>
{email && <span>{email}</span>}
{email && address && <span> &middot; </span>}
{address && <span>{address}</span>}
{!email && !address && <span>Keine Details</span>}
</div>
</div>
<button
className={styles.searchResultBtn}
disabled={isLinking}
onClick={() => onLink(contact.id)}
>
{isLinking ? '...' : 'Verknüpfen'}
</button>
</div>
);
})}
</div>
</Modal>
);
}

View file

@ -0,0 +1,360 @@
/* ============================================================
Lexware Office Shared Section Styles
============================================================ */
.card {
background: var(--color-bg-card);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
border: 1px solid var(--color-border);
padding: 1.5rem;
}
.cardHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
gap: 0.75rem;
}
.cardTitle {
font-size: 1rem;
font-weight: 600;
margin: 0;
color: var(--color-text);
display: flex;
align-items: center;
gap: 0.5rem;
}
.lexwareLogo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
background: #0066cc;
color: white;
font-size: 0.625rem;
font-weight: 700;
flex-shrink: 0;
}
/* Status badge */
.statusBadge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.125rem 0.625rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.statusLinked {
background: #d1fae5;
color: #065f46;
}
.statusNotLinked {
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
}
:global([data-theme='dark']) .statusLinked {
background: #064e3b;
color: #6ee7b7;
}
/* Sync info */
.syncInfo {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-bottom: 1rem;
}
/* Action buttons row */
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 1rem;
}
.actionBtn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
color: var(--color-text-secondary);
transition: border-color 0.15s, color 0.15s;
}
.actionBtn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.actionBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.actionBtnDanger {
composes: actionBtn;
border-color: #fecaca;
color: var(--color-error);
}
.actionBtnDanger:hover {
border-color: var(--color-error);
}
.actionBtnPrimary {
composes: actionBtn;
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
.actionBtnPrimary:hover {
opacity: 0.9;
}
/* Voucher table */
.voucherTable {
width: 100%;
border-collapse: collapse;
}
.voucherTable th {
padding: 0.5rem 0.5rem 0.5rem 0;
text-align: left;
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: uppercase;
font-weight: 500;
border-bottom: 1px solid var(--color-border);
}
.voucherTable th:last-child {
text-align: right;
padding-right: 0;
}
.voucherTable td {
padding: 0.5rem 0.5rem 0.5rem 0;
font-size: 0.8125rem;
border-bottom: 1px solid var(--color-border);
vertical-align: middle;
}
.voucherTable td:last-child {
text-align: right;
padding-right: 0;
}
.voucherTable tbody tr:last-child td {
border-bottom: none;
}
.voucherTypeBadge {
display: inline-block;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
font-size: 0.6875rem;
font-weight: 500;
white-space: nowrap;
}
.typeQuotation {
background: #dbeafe;
color: #1e40af;
}
.typeOrderConfirmation {
background: #e0e7ff;
color: #3730a3;
}
.typeInvoice {
background: #d1fae5;
color: #065f46;
}
.typeCreditNote {
background: #fef3c7;
color: #92400e;
}
:global([data-theme='dark']) .typeQuotation {
background: #1e3a5f;
color: #93c5fd;
}
:global([data-theme='dark']) .typeOrderConfirmation {
background: #312e81;
color: #a5b4fc;
}
:global([data-theme='dark']) .typeInvoice {
background: #064e3b;
color: #6ee7b7;
}
:global([data-theme='dark']) .typeCreditNote {
background: #78350f;
color: #fde68a;
}
.voucherLink {
color: var(--color-primary);
text-decoration: none;
font-size: 0.75rem;
}
.voucherLink:hover {
text-decoration: underline;
}
.statusOpen {
color: var(--color-primary);
}
.statusPaid {
color: var(--color-success);
}
.statusOverdue {
color: var(--color-error);
}
/* Empty state */
.emptyText {
color: var(--color-text-muted);
font-size: 0.875rem;
}
/* Filter row for vouchers */
.filterRow {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.filterSelect {
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
cursor: pointer;
}
/* Search modal */
.searchInput {
width: 100%;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.searchInput:focus {
border-color: var(--color-primary);
}
.searchResults {
margin-top: 0.75rem;
max-height: 320px;
overflow-y: auto;
}
.searchResultItem {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
margin-bottom: 0.5rem;
transition: border-color 0.15s;
cursor: default;
}
.searchResultItem:hover {
border-color: var(--color-primary);
}
.searchResultInfo {
flex: 1;
min-width: 0;
}
.searchResultName {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
}
.searchResultMeta {
font-size: 0.75rem;
color: var(--color-text-muted);
margin-top: 0.125rem;
}
.searchResultBtn {
padding: 0.25rem 0.625rem;
font-size: 0.75rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.searchResultBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loadingText {
font-size: 0.8125rem;
color: var(--color-text-muted);
padding: 1rem 0;
text-align: center;
}
/* ERP Tag highlight */
.erpTag {
display: inline-block;
padding: 0.125rem 0.5rem;
background: #0066cc;
border: none;
border-radius: 9999px;
font-size: 0.75rem;
color: white;
font-weight: 600;
}
:global([data-theme='dark']) .erpTag {
background: #2563eb;
}

View file

@ -0,0 +1,398 @@
import { useState } from 'react';
import {
useLinkLexwareCompany,
useLinkLexwareContact,
useUnlinkLexwareCompany,
useUnlinkLexwareContact,
usePushToLexware,
useSyncFromLexware,
useCompanyVouchers,
useContactVouchers,
useRefreshCompanyVouchers,
useRefreshContactVouchers,
} from '../hooks';
import { useCrmSettings } from '../settings/CrmSettingsContext';
import type { LexwareVoucher, VoucherType } from '../types';
import { VOUCHER_TYPE_LABELS } from '../types';
import { LexwareSearchModal } from './LexwareSearchModal';
import styles from './LexwareSection.module.css';
// ============================================================
// Helpers
// ============================================================
const currencyFormatter = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
});
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
function voucherTypeClass(type: VoucherType): string {
switch (type) {
case 'QUOTATION':
return styles.typeQuotation;
case 'ORDER_CONFIRMATION':
return styles.typeOrderConfirmation;
case 'INVOICE':
return styles.typeInvoice;
case 'CREDIT_NOTE':
return styles.typeCreditNote;
default:
return '';
}
}
function voucherStatusClass(status: string | null): string {
if (!status) return '';
const lower = status.toLowerCase();
if (lower === 'paid' || lower === 'bezahlt') return styles.statusPaid;
if (lower === 'overdue' || lower === 'überfällig') return styles.statusOverdue;
if (lower === 'open' || lower === 'offen') return styles.statusOpen;
return '';
}
// ============================================================
// VouchersTable
// ============================================================
function VouchersTable({ vouchers }: { vouchers: LexwareVoucher[] }) {
if (vouchers.length === 0) {
return <p className={styles.emptyText}>Keine Belege vorhanden</p>;
}
return (
<table className={styles.voucherTable}>
<thead>
<tr>
<th>Typ</th>
<th>Nummer</th>
<th>Datum</th>
<th>Status</th>
<th>Betrag</th>
</tr>
</thead>
<tbody>
{vouchers.map((v) => (
<tr key={v.id}>
<td>
<span
className={`${styles.voucherTypeBadge} ${voucherTypeClass(v.voucherType)}`}
>
{VOUCHER_TYPE_LABELS[v.voucherType]}
</span>
</td>
<td>
{v.lexwareDeepLink ? (
<a
href={v.lexwareDeepLink}
target="_blank"
rel="noopener noreferrer"
className={styles.voucherLink}
style={{ fontSize: '0.8125rem' }}
>
{v.voucherNumber ?? '—'}
</a>
) : (
v.voucherNumber ?? '—'
)}
</td>
<td>{v.voucherDate ? formatDate(v.voucherDate) : '—'}</td>
<td>
<span className={voucherStatusClass(v.voucherStatus)}>
{v.voucherStatus ?? '—'}
</span>
</td>
<td>
{v.totalGrossAmount
? currencyFormatter.format(parseFloat(v.totalGrossAmount))
: '—'}
</td>
</tr>
))}
</tbody>
</table>
);
}
// ============================================================
// LexwareSection — Main Component
// ============================================================
interface LexwareSectionProps {
entityType: 'company' | 'contact';
entityId: string;
lexwareContactId: string | null;
lexwareSyncedAt: string | null;
}
export function LexwareSection({
entityType,
entityId,
lexwareContactId,
lexwareSyncedAt,
}: LexwareSectionProps) {
const { isModuleEnabled } = useCrmSettings();
const [isSearchOpen, setSearchOpen] = useState(false);
const [voucherTypeFilter, setVoucherTypeFilter] = useState<
VoucherType | ''
>('');
// Hide entire section if Lexware module is disabled
if (!isModuleEnabled('lexware')) return null;
const isLinked = !!lexwareContactId;
// Mutations
const linkCompany = useLinkLexwareCompany();
const linkContact = useLinkLexwareContact();
const unlinkCompany = useUnlinkLexwareCompany();
const unlinkContact = useUnlinkLexwareContact();
const pushToLexware = usePushToLexware();
const syncFromLexware = useSyncFromLexware();
const refreshCompanyVouchers = useRefreshCompanyVouchers();
const refreshContactVouchers = useRefreshContactVouchers();
// Vouchers (only fetch when linked)
const vouchersParams = voucherTypeFilter
? { voucherType: voucherTypeFilter as VoucherType, pageSize: 50 }
: { pageSize: 50 };
const companyVouchers = useCompanyVouchers(
entityType === 'company' ? entityId : '',
entityType === 'company' && isLinked ? vouchersParams : undefined,
);
const contactVouchers = useContactVouchers(
entityType === 'contact' ? entityId : '',
entityType === 'contact' && isLinked ? vouchersParams : undefined,
);
const vouchersData =
entityType === 'company' ? companyVouchers : contactVouchers;
const vouchers: LexwareVoucher[] = vouchersData.data?.data ?? [];
const isLoadingVouchers = vouchersData.isLoading;
const isAnyMutating =
linkCompany.isPending ||
linkContact.isPending ||
unlinkCompany.isPending ||
unlinkContact.isPending ||
pushToLexware.isPending ||
syncFromLexware.isPending ||
refreshCompanyVouchers.isPending ||
refreshContactVouchers.isPending;
const handleLink = (lexwareId: string) => {
if (entityType === 'company') {
linkCompany.mutate(
{ lexwareContactId: lexwareId, companyId: entityId },
{ onSuccess: () => setSearchOpen(false) },
);
} else {
linkContact.mutate(
{ lexwareContactId: lexwareId, contactId: entityId },
{ onSuccess: () => setSearchOpen(false) },
);
}
};
const handleUnlink = () => {
if (entityType === 'company') {
unlinkCompany.mutate(entityId);
} else {
unlinkContact.mutate(entityId);
}
};
const handlePush = () => {
pushToLexware.mutate({ entityType, entityId });
};
const handleSync = () => {
syncFromLexware.mutate({ entityType, entityId });
};
const handleRefresh = () => {
if (entityType === 'company') {
refreshCompanyVouchers.mutate(entityId);
} else {
refreshContactVouchers.mutate(entityId);
}
};
return (
<div className={styles.card}>
{/* Header */}
<div className={styles.cardHeader}>
<h2 className={styles.cardTitle}>
<span className={styles.lexwareLogo}>LX</span>
Lexware Office
</h2>
<span
className={`${styles.statusBadge} ${
isLinked ? styles.statusLinked : styles.statusNotLinked
}`}
>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: isLinked ? '#059669' : 'var(--color-text-muted)',
display: 'inline-block',
}}
/>
{isLinked ? 'Verknüpft' : 'Nicht verknüpft'}
</span>
</div>
{/* Sync info */}
{isLinked && lexwareSyncedAt && (
<p className={styles.syncInfo}>
Letzte Synchronisation: {formatDate(lexwareSyncedAt)}
</p>
)}
{/* Action buttons */}
<div className={styles.actions}>
{!isLinked ? (
<button
className={styles.actionBtnPrimary}
onClick={() => setSearchOpen(true)}
disabled={isAnyMutating}
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="7" cy="7" r="5" />
<path d="M11 11l3.5 3.5" />
</svg>
Lexware Kontakt suchen
</button>
) : (
<>
<button
className={styles.actionBtn}
onClick={handleSync}
disabled={isAnyMutating}
title="Daten aus Lexware aktualisieren"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 8a7 7 0 0112.9-3.8M15 8a7 7 0 01-12.9 3.8" />
<path d="M14 1v3.2h-3.2M2 15v-3.2h3.2" />
</svg>
{syncFromLexware.isPending ? 'Sync...' : 'Sync'}
</button>
<button
className={styles.actionBtn}
onClick={handlePush}
disabled={isAnyMutating}
title="Daten zu Lexware übertragen"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M8 12V3M4 7l4-4 4 4" />
</svg>
{pushToLexware.isPending ? 'Push...' : 'Push'}
</button>
<button
className={styles.actionBtn}
onClick={handleRefresh}
disabled={isAnyMutating}
title="Belege aus Lexware neu laden"
>
<svg
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 1v5h5M15 15v-5h-5" />
<path d="M13.5 6A6 6 0 003.3 3.3L1 6M2.5 10a6 6 0 0010.2 2.7L15 10" />
</svg>
Belege aktualisieren
</button>
<button
className={styles.actionBtnDanger}
onClick={handleUnlink}
disabled={isAnyMutating}
title="Verknüpfung aufheben"
>
{(unlinkCompany.isPending || unlinkContact.isPending)
? 'Trennen...'
: 'Trennen'}
</button>
</>
)}
</div>
{/* Vouchers */}
{isLinked && (
<>
<div className={styles.filterRow}>
<select
className={styles.filterSelect}
value={voucherTypeFilter}
onChange={(e) =>
setVoucherTypeFilter(e.target.value as VoucherType | '')
}
>
<option value="">Alle Belegarten</option>
<option value="QUOTATION">Angebote</option>
<option value="ORDER_CONFIRMATION">Auftragsbestätigungen</option>
<option value="INVOICE">Rechnungen</option>
<option value="CREDIT_NOTE">Gutschriften</option>
</select>
</div>
{isLoadingVouchers ? (
<p className={styles.loadingText}>Belege werden geladen...</p>
) : (
<VouchersTable vouchers={vouchers} />
)}
</>
)}
{/* Search Modal */}
<LexwareSearchModal
isOpen={isSearchOpen}
onClose={() => setSearchOpen(false)}
onLink={handleLink}
isLinking={linkCompany.isPending || linkContact.isPending}
/>
</div>
);
}

View file

@ -11,7 +11,7 @@ import { Navigate } from 'react-router-dom';
// Types
// ============================================================
export type CrmModuleKey = 'contacts' | 'companies' | 'deals' | 'pipelines';
export type CrmModuleKey = 'contacts' | 'companies' | 'deals' | 'pipelines' | 'lexware';
export interface CrmModuleConfig {
enabled: boolean;
@ -41,6 +41,7 @@ const DEFAULT_SETTINGS: CrmSettings = {
companies: { enabled: true },
deals: { enabled: true },
pipelines: { enabled: true },
lexware: { enabled: true },
},
};

View file

@ -76,6 +76,18 @@ const MODULES: ModuleDef[] = [
</svg>
),
},
{
key: 'lexware',
name: 'Lexware Office',
description: 'Lexware-Kontaktverknüpfung & Belegansicht auf Detail-Seiten',
icon: (
<svg {...iconProps}>
<rect x="1" y="3" width="14" height="10" rx="1" />
<path d="M4 7h8M4 10h5" />
<circle cx="12" cy="10" r="1" />
</svg>
),
},
];
// ============================================================

View file

@ -35,6 +35,9 @@ export interface Contact {
updatedBy: string | null;
createdAt: string;
updatedAt: string;
lexwareContactId: string | null;
lexwareContactVersion: number | null;
lexwareSyncedAt: string | null;
activities?: Activity[];
company?: {
id: string;
@ -180,6 +183,7 @@ export interface Deal {
companyName: string | null;
} | null;
company?: { id: string; name: string } | null;
dealVouchers?: DealVoucher[];
}
export interface CreateDealPayload {
@ -219,7 +223,10 @@ export interface Company {
updatedBy: string | null;
createdAt: string;
updatedAt: string;
_count?: { contacts: number; deals: number };
lexwareContactId: string | null;
lexwareContactVersion: number | null;
lexwareSyncedAt: string | null;
_count?: { contacts: number; deals: number; lexwareVouchers?: number };
contacts?: {
id: string;
firstName: string | null;
@ -313,3 +320,69 @@ export interface CompaniesQueryParams {
sort?: string;
order?: 'asc' | 'desc';
}
// --- Lexware Office Integration ---
export type VoucherType =
| 'QUOTATION'
| 'ORDER_CONFIRMATION'
| 'INVOICE'
| 'CREDIT_NOTE';
export const VOUCHER_TYPE_LABELS: Record<VoucherType, string> = {
QUOTATION: 'Angebot',
ORDER_CONFIRMATION: 'Auftragsbestätigung',
INVOICE: 'Rechnung',
CREDIT_NOTE: 'Gutschrift',
};
export interface LexwareVoucher {
id: string;
voucherType: VoucherType;
voucherNumber: string | null;
voucherDate: string | null;
voucherStatus: string | null;
totalGrossAmount: string | null;
totalNetAmount: string | null;
totalTaxAmount: string | null;
currency: string;
title: string | null;
lineItemsCount: number | null;
lineItemsJson: string | null;
lexwareDeepLink: string | null;
fetchedAt: string;
}
export interface DealVoucher {
id: string;
linkedAt: string;
voucher: LexwareVoucher;
}
export interface LexwareContact {
id: string;
version: number;
roles: { customer?: Record<string, unknown>; vendor?: Record<string, unknown> };
company?: { name?: string; taxId?: string; allowTaxFreeInvoices?: boolean };
person?: { firstName?: string; lastName?: string; salutation?: string };
addresses?: {
billing?: { street?: string; zip?: string; city?: string; countryCode?: string }[];
shipping?: { street?: string; zip?: string; city?: string; countryCode?: string }[];
};
emailAddresses?: { business?: string[]; office?: string[]; private?: string[]; other?: string[] };
phoneNumbers?: { business?: string[]; office?: string[]; mobile?: string[]; private?: string[]; fax?: string[] };
note?: string;
archived?: boolean;
}
export interface LexwareContactSearchParams {
name?: string;
email?: string;
}
export interface LexwareVouchersQueryParams {
voucherType?: VoucherType;
voucherStatus?: string;
page?: number;
pageSize?: number;
}