From 2381409e6d6a6f792e972c1a740e47b064f1d41c Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Tue, 10 Mar 2026 22:20:18 +0100 Subject: [PATCH] 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 --- packages/frontend/src/crm/api.ts | 126 ++++++ .../src/crm/companies/CompanyDetailPage.tsx | 11 +- .../src/crm/contacts/ContactDetailPage.tsx | 11 + .../frontend/src/crm/deals/DealDetailPage.tsx | 8 + packages/frontend/src/crm/hooks.ts | 200 ++++++++- .../src/crm/lexware/DealVouchersSection.tsx | 324 ++++++++++++++ .../src/crm/lexware/LexwareSearchModal.tsx | 147 +++++++ .../src/crm/lexware/LexwareSection.module.css | 360 ++++++++++++++++ .../src/crm/lexware/LexwareSection.tsx | 398 ++++++++++++++++++ .../src/crm/settings/CrmSettingsContext.tsx | 3 +- .../src/crm/settings/CrmSettingsPage.tsx | 12 + packages/frontend/src/crm/types.ts | 75 +++- 12 files changed, 1671 insertions(+), 4 deletions(-) create mode 100644 packages/frontend/src/crm/lexware/DealVouchersSection.tsx create mode 100644 packages/frontend/src/crm/lexware/LexwareSearchModal.tsx create mode 100644 packages/frontend/src/crm/lexware/LexwareSection.module.css create mode 100644 packages/frontend/src/crm/lexware/LexwareSection.tsx diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index 713f0f6..0b73379 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -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>(`/crm/companies/${id}`) .then((r) => r.data), }; + +// --- Lexware Office: Contacts --- + +export const lexwareContactsApi = { + search: (params: LexwareContactSearchParams) => + api + .get>('/crm/lexware/contacts/search', { + params, + }) + .then((r) => r.data), + + linkCompany: (data: { lexwareContactId: string; companyId: string }) => + api + .post>('/crm/lexware/contacts/link-company', data) + .then((r) => r.data), + + linkContact: (data: { lexwareContactId: string; contactId: string }) => + api + .post>('/crm/lexware/contacts/link-contact', data) + .then((r) => r.data), + + unlinkCompany: (companyId: string) => + api + .delete>( + `/crm/lexware/contacts/unlink-company/${companyId}`, + ) + .then((r) => r.data), + + unlinkContact: (contactId: string) => + api + .delete>( + `/crm/lexware/contacts/unlink-contact/${contactId}`, + ) + .then((r) => r.data), + + importCompany: (data: { lexwareContactId: string }) => + api + .post>( + '/crm/lexware/contacts/import-company', + data, + ) + .then((r) => r.data), + + importContact: (data: { lexwareContactId: string }) => + api + .post>( + '/crm/lexware/contacts/import-contact', + data, + ) + .then((r) => r.data), + + push: (entityType: 'company' | 'contact', entityId: string) => + api + .post>( + `/crm/lexware/contacts/push/${entityType}/${entityId}`, + ) + .then((r) => r.data), + + sync: (entityType: 'company' | 'contact', entityId: string) => + api + .post>( + `/crm/lexware/contacts/sync/${entityType}/${entityId}`, + ) + .then((r) => r.data), +}; + +// --- Lexware Office: Vouchers --- + +export const lexwareVouchersApi = { + getForCompany: (companyId: string, params?: LexwareVouchersQueryParams) => + api + .get>( + `/crm/lexware/vouchers/company/${companyId}`, + { params }, + ) + .then((r) => r.data), + + getForContact: (contactId: string, params?: LexwareVouchersQueryParams) => + api + .get>( + `/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>( + `/crm/lexware/vouchers/deal/${dealId}/link`, + { voucherId }, + ) + .then((r) => r.data), + + unlinkFromDeal: (dealId: string, voucherId: string) => + api + .delete>( + `/crm/lexware/vouchers/deal/${dealId}/unlink/${voucherId}`, + ) + .then((r) => r.data), + + refreshCompany: (companyId: string) => + api + .post>( + `/crm/lexware/vouchers/refresh/company/${companyId}`, + ) + .then((r) => r.data), + + refreshContact: (contactId: string) => + api + .post>( + `/crm/lexware/vouchers/refresh/contact/${contactId}`, + ) + .then((r) => r.data), +}; diff --git a/packages/frontend/src/crm/companies/CompanyDetailPage.tsx b/packages/frontend/src/crm/companies/CompanyDetailPage.tsx index 1d866b7..ceb3b83 100644 --- a/packages/frontend/src/crm/companies/CompanyDetailPage.tsx +++ b/packages/frontend/src/crm/companies/CompanyDetailPage.tsx @@ -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() { - {/* Rechts: Kontakte + Vorgänge */} + {/* Rechts: Kontakte + Vorgänge + Lexware */}
{/* Kontakte */}
@@ -456,6 +457,14 @@ export function CompanyDetailPage() { )}
+ + {/* Lexware Office */} +
diff --git a/packages/frontend/src/crm/contacts/ContactDetailPage.tsx b/packages/frontend/src/crm/contacts/ContactDetailPage.tsx index 1ec1798..30c679f 100644 --- a/packages/frontend/src/crm/contacts/ContactDetailPage.tsx +++ b/packages/frontend/src/crm/contacts/ContactDetailPage.tsx @@ -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() { )} + + {/* Lexware Office */} +
+ +
{/* Rechts: Aktivitäten-Timeline */} diff --git a/packages/frontend/src/crm/deals/DealDetailPage.tsx b/packages/frontend/src/crm/deals/DealDetailPage.tsx index 13db473..6f1264c 100644 --- a/packages/frontend/src/crm/deals/DealDetailPage.tsx +++ b/packages/frontend/src/crm/deals/DealDetailPage.tsx @@ -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() { )} + {/* Belege (Lexware Vouchers) */} + + {/* Modals */} ['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 }); + }, + }); +} diff --git a/packages/frontend/src/crm/lexware/DealVouchersSection.tsx b/packages/frontend/src/crm/lexware/DealVouchersSection.tsx new file mode 100644 index 0000000..3627587 --- /dev/null +++ b/packages/frontend/src/crm/lexware/DealVouchersSection.tsx @@ -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; +} + +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 ( + + {!companyId && !contactId && ( +

+ Dieser Vorgang hat kein verknüpftes Unternehmen oder Kontakt mit + Lexware-Verbindung. Bitte zuerst einen Lexware-Kontakt verknüpfen. +

+ )} + + {(companyId || contactId) && sourceData.isLoading && ( +

Belege werden geladen...

+ )} + + {(companyId || contactId) && !sourceData.isLoading && available.length === 0 && ( +

+ Keine weiteren Belege zum Verknüpfen verfügbar +

+ )} + + {available.length > 0 && ( +
+ {available.map((v) => ( +
+
+
+ + {VOUCHER_TYPE_LABELS[v.voucherType]} + + {v.voucherNumber ?? 'Ohne Nummer'} +
+
+ {v.voucherDate ? formatDate(v.voucherDate) : '—'} + {v.totalGrossAmount && ( + <> + {' · '} + {currencyFormatter.format( + parseFloat(v.totalGrossAmount), + )} + + )} + {v.title && <> · {v.title}} +
+
+ +
+ ))} +
+ )} +
+ ); +} + +// ============================================================ +// 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 ( +
+ {/* Header */} +
+

+ LX + Belege ({dealVouchers.length}) +

+ +
+ + {isLoading ? ( +

Belege werden geladen...

+ ) : dealVouchers.length === 0 ? ( +

+ Keine Belege mit diesem Vorgang verknüpft +

+ ) : ( + + + + + + + + + + + + {dealVouchers.map((dv) => { + const v = dv.voucher; + return ( + + + + + + + + + ); + })} + +
TypNummerDatumStatusBetrag +
+ + {VOUCHER_TYPE_LABELS[v.voucherType]} + + + {v.lexwareDeepLink ? ( + + {v.voucherNumber ?? '—'} + + ) : ( + v.voucherNumber ?? '—' + )} + + {v.voucherDate ? formatDate(v.voucherDate) : '—'} + + + {v.voucherStatus ?? '—'} + + + {v.totalGrossAmount + ? currencyFormatter.format( + parseFloat(v.totalGrossAmount), + ) + : '—'} + + +
+ )} + + {/* Link Voucher Modal */} + setLinkOpen(false)} + dealId={dealId} + companyId={companyId} + contactId={contactId} + linkedVoucherIds={linkedVoucherIds} + /> +
+ ); +} diff --git a/packages/frontend/src/crm/lexware/LexwareSearchModal.tsx b/packages/frontend/src/crm/lexware/LexwareSearchModal.tsx new file mode 100644 index 0000000..2a8597b --- /dev/null +++ b/packages/frontend/src/crm/lexware/LexwareSearchModal.tsx @@ -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(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 ( + + setSearchTerm(e.target.value)} + /> + +
+ {debouncedTerm.length < 2 && ( +

+ Mindestens 2 Zeichen eingeben... +

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

Suche in Lexware...

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

+ Keine Ergebnisse gefunden +

+ )} + + {results.map((contact) => { + const email = lexwareEmail(contact); + const address = lexwareAddress(contact); + return ( +
+
+
+ {lexwareDisplayName(contact)} +
+
+ {email && {email}} + {email && address && · } + {address && {address}} + {!email && !address && Keine Details} +
+
+ +
+ ); + })} +
+
+ ); +} diff --git a/packages/frontend/src/crm/lexware/LexwareSection.module.css b/packages/frontend/src/crm/lexware/LexwareSection.module.css new file mode 100644 index 0000000..d36a9f5 --- /dev/null +++ b/packages/frontend/src/crm/lexware/LexwareSection.module.css @@ -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; +} diff --git a/packages/frontend/src/crm/lexware/LexwareSection.tsx b/packages/frontend/src/crm/lexware/LexwareSection.tsx new file mode 100644 index 0000000..e8ac3da --- /dev/null +++ b/packages/frontend/src/crm/lexware/LexwareSection.tsx @@ -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

Keine Belege vorhanden

; + } + + return ( + + + + + + + + + + + + {vouchers.map((v) => ( + + + + + + + + ))} + +
TypNummerDatumStatusBetrag
+ + {VOUCHER_TYPE_LABELS[v.voucherType]} + + + {v.lexwareDeepLink ? ( + + {v.voucherNumber ?? '—'} + + ) : ( + v.voucherNumber ?? '—' + )} + {v.voucherDate ? formatDate(v.voucherDate) : '—'} + + {v.voucherStatus ?? '—'} + + + {v.totalGrossAmount + ? currencyFormatter.format(parseFloat(v.totalGrossAmount)) + : '—'} +
+ ); +} + +// ============================================================ +// 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 ( +
+ {/* Header */} +
+

+ LX + Lexware Office +

+ + + {isLinked ? 'Verknüpft' : 'Nicht verknüpft'} + +
+ + {/* Sync info */} + {isLinked && lexwareSyncedAt && ( +

+ Letzte Synchronisation: {formatDate(lexwareSyncedAt)} +

+ )} + + {/* Action buttons */} +
+ {!isLinked ? ( + + ) : ( + <> + + + + + + )} +
+ + {/* Vouchers */} + {isLinked && ( + <> +
+ +
+ + {isLoadingVouchers ? ( +

Belege werden geladen...

+ ) : ( + + )} + + )} + + {/* Search Modal */} + setSearchOpen(false)} + onLink={handleLink} + isLinking={linkCompany.isPending || linkContact.isPending} + /> +
+ ); +} diff --git a/packages/frontend/src/crm/settings/CrmSettingsContext.tsx b/packages/frontend/src/crm/settings/CrmSettingsContext.tsx index abb43c2..8ba7acf 100644 --- a/packages/frontend/src/crm/settings/CrmSettingsContext.tsx +++ b/packages/frontend/src/crm/settings/CrmSettingsContext.tsx @@ -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 }, }, }; diff --git a/packages/frontend/src/crm/settings/CrmSettingsPage.tsx b/packages/frontend/src/crm/settings/CrmSettingsPage.tsx index ea63050..113235f 100644 --- a/packages/frontend/src/crm/settings/CrmSettingsPage.tsx +++ b/packages/frontend/src/crm/settings/CrmSettingsPage.tsx @@ -76,6 +76,18 @@ const MODULES: ModuleDef[] = [ ), }, + { + key: 'lexware', + name: 'Lexware Office', + description: 'Lexware-Kontaktverknüpfung & Belegansicht auf Detail-Seiten', + icon: ( + + + + + + ), + }, ]; // ============================================================ diff --git a/packages/frontend/src/crm/types.ts b/packages/frontend/src/crm/types.ts index 871f7d7..029bf7f 100644 --- a/packages/frontend/src/crm/types.ts +++ b/packages/frontend/src/crm/types.ts @@ -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 = { + 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; vendor?: Record }; + 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; +}