diff --git a/packages/frontend/src/crm/hooks.ts b/packages/frontend/src/crm/hooks.ts index 94e19f7..510da03 100644 --- a/packages/frontend/src/crm/hooks.ts +++ b/packages/frontend/src/crm/hooks.ts @@ -487,6 +487,30 @@ export function useSyncFromLexware() { }); } +export function useImportLexwareAsCompany() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { lexwareContactId: string }) => + lexwareContactsApi.importCompany(data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: crmKeys.companies.all }); + qc.invalidateQueries({ queryKey: crmKeys.lexware.all }); + }, + }); +} + +export function useImportLexwareAsContact() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (data: { lexwareContactId: string }) => + lexwareContactsApi.importContact(data), + onSuccess: () => { + qc.invalidateQueries({ queryKey: crmKeys.contacts.all }); + qc.invalidateQueries({ queryKey: crmKeys.lexware.all }); + }, + }); +} + // ============================================================ // Lexware Office — Vouchers // ============================================================ diff --git a/packages/frontend/src/crm/lexware/LexwareSyncPage.module.css b/packages/frontend/src/crm/lexware/LexwareSyncPage.module.css new file mode 100644 index 0000000..531c630 --- /dev/null +++ b/packages/frontend/src/crm/lexware/LexwareSyncPage.module.css @@ -0,0 +1,438 @@ +/* Lexware Sync Page */ + +.backLink { + display: inline-flex; + align-items: center; + gap: 0.375rem; + color: var(--color-text-muted); + font-size: 0.875rem; + text-decoration: none; + margin-bottom: 1.5rem; +} + +.backLink:hover { + color: var(--color-text); +} + +.header { + margin-bottom: 1.5rem; +} + +.title { + display: flex; + align-items: center; + gap: 0.625rem; + font-size: 1.5rem; + font-weight: 600; + margin: 0; +} + +.lxBadge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 6px; + background: linear-gradient(135deg, #2563eb, #7c3aed); + color: white; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.5px; + flex-shrink: 0; +} + +/* Tab Bar */ +.tabBar { + display: flex; + gap: 0; + border-bottom: 2px solid var(--color-border); + margin-bottom: 0; +} + +.tab { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + font-size: 0.9375rem; + font-weight: 500; + color: var(--color-text-muted); + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.tab:hover { + color: var(--color-text); +} + +.tabActive { + color: var(--color-primary); + border-bottom-color: var(--color-primary); +} + +/* Card (tab content wrapper) */ +.card { + background: var(--color-bg-card); + border-radius: 0 0 var(--radius-md) var(--radius-md); + box-shadow: var(--shadow-sm); + border: 1px solid var(--color-border); + border-top: none; + padding: 1.5rem; +} + +.tabDesc { + font-size: 0.875rem; + color: var(--color-text-muted); + margin: 0 0 1rem; +} + +/* Search input */ +.searchInput { + width: 100%; + padding: 0.625rem 0.875rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.9375rem; + background: var(--color-bg); + color: var(--color-text); + outline: none; + transition: border-color 0.15s; + box-sizing: border-box; +} + +.searchInput:focus { + border-color: var(--color-primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.searchInput::placeholder { + color: var(--color-text-muted); +} + +/* Results list */ +.resultsList { + margin-top: 1rem; +} + +.hint { + text-align: center; + color: var(--color-text-muted); + font-size: 0.875rem; + padding: 1.5rem 0; + margin: 0; +} + +/* Result card */ +.resultCard { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.875rem 0; + border-bottom: 1px solid var(--color-border); +} + +.resultCard:last-child { + border-bottom: none; +} + +.resultInfo { + flex: 1; + min-width: 0; +} + +.resultName { + font-size: 0.9375rem; + font-weight: 500; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.resultMeta { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + font-size: 0.8125rem; + color: var(--color-text-muted); + margin-top: 0.125rem; +} + +.roleBadge { + display: inline-block; + padding: 0 0.375rem; + border-radius: 4px; + background: #ede9fe; + color: #6d28d9; + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.025em; +} + +:global([data-theme='dark']) .roleBadge { + background: #4c1d95; + color: #c4b5fd; +} + +.resultActions { + display: flex; + gap: 0.5rem; + flex-shrink: 0; +} + +/* Import buttons */ +.importBtn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg-card); + color: var(--color-text-secondary); + font-size: 0.8125rem; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.importBtn:hover:not(:disabled) { + border-color: var(--color-primary); + color: var(--color-primary); + background: var(--color-primary-bg, #eff6ff); +} + +.importBtn:disabled { + opacity: 0.5; + cursor: wait; +} + +/* Success / Error banners */ +.successBanner { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 0.875rem; + background: #d1fae5; + border: 1px solid #a7f3d0; + border-radius: var(--radius-sm); + color: #065f46; + font-size: 0.875rem; + font-weight: 500; + margin-top: 0.75rem; +} + +:global([data-theme='dark']) .successBanner { + background: #064e3b; + border-color: #065f46; + color: #a7f3d0; +} + +.errorBanner { + padding: 0.625rem 0.875rem; + background: #fee2e2; + border: 1px solid #fecaca; + border-radius: var(--radius-sm); + color: #991b1b; + font-size: 0.875rem; + font-weight: 500; + margin-top: 0.75rem; +} + +:global([data-theme='dark']) .errorBanner { + background: #7f1d1d; + border-color: #991b1b; + color: #fecaca; +} + +/* Filter tabs (Export) */ +.filterTabs { + display: flex; + gap: 0.5rem; + margin-bottom: 1rem; +} + +.filterTab { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + border: 1px solid var(--color-border); + border-radius: 9999px; + background: var(--color-bg); + color: var(--color-text-muted); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s; +} + +.filterTab:hover { + color: var(--color-text); + border-color: var(--color-text-muted); +} + +.filterTabActive { + background: var(--color-primary); + color: white; + border-color: var(--color-primary); +} + +.filterTabActive:hover { + color: white; + border-color: var(--color-primary); +} + +.countBadge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 20px; + height: 20px; + padding: 0 0.375rem; + border-radius: 9999px; + background: rgba(0, 0, 0, 0.1); + font-size: 0.75rem; + font-weight: 600; +} + +.filterTabActive .countBadge { + background: rgba(255, 255, 255, 0.2); +} + +/* Export sections */ +.exportSection { + margin-bottom: 1.5rem; +} + +.exportSection:last-child { + margin-bottom: 0; +} + +.exportSectionTitle { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.025em; + margin: 0 0 0.75rem; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--color-border); +} + +.linkedDot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #059669; + display: inline-block; +} + +.exportList { + display: flex; + flex-direction: column; + gap: 0; +} + +.exportItem { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.625rem 0; + border-bottom: 1px solid var(--color-border); +} + +.exportItem:last-child { + border-bottom: none; +} + +.exportItemLinked { + opacity: 0.75; +} + +.exportItemInfo { + flex: 1; + min-width: 0; +} + +.exportItemName { + display: block; + font-size: 0.9375rem; + font-weight: 500; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.exportItemMeta { + display: block; + font-size: 0.8125rem; + color: var(--color-text-muted); + margin-top: 0.125rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Export button */ +.exportBtn { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border: none; + border-radius: var(--radius-sm); + background: var(--color-primary); + color: white; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: opacity 0.15s; + white-space: nowrap; +} + +.exportBtn:hover:not(:disabled) { + opacity: 0.85; +} + +.exportBtn:disabled { + opacity: 0.5; + cursor: wait; +} + +.exportBtnSecondary { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-bg-card); + color: var(--color-text-muted); + font-size: 0.8125rem; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.exportBtnSecondary:hover:not(:disabled) { + color: var(--color-text); + border-color: var(--color-text-muted); +} + +.exportBtnSecondary:disabled { + opacity: 0.5; + cursor: wait; +} diff --git a/packages/frontend/src/crm/lexware/LexwareSyncPage.tsx b/packages/frontend/src/crm/lexware/LexwareSyncPage.tsx new file mode 100644 index 0000000..32b71d0 --- /dev/null +++ b/packages/frontend/src/crm/lexware/LexwareSyncPage.tsx @@ -0,0 +1,666 @@ +import { useState, useEffect, useRef } from 'react'; +import { Link, Navigate } from 'react-router-dom'; +import { useAuth } from '../../auth/AuthContext'; +import { useCrmSettings } from '../settings/CrmSettingsContext'; +import { + useLexwareContactSearch, + useImportLexwareAsCompany, + useImportLexwareAsContact, + useCompanies, + useContacts, + usePushToLexware, +} from '../hooks'; +import type { LexwareContact, Company, Contact } from '../types'; +import styles from './LexwareSyncPage.module.css'; + +// ============================================================ +// Helpers +// ============================================================ + +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'; +} + +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 + ); +} + +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; +} + +function lexwareRoles(c: LexwareContact): string { + const roles: string[] = []; + if (c.roles?.customer) roles.push('Kunde'); + if (c.roles?.vendor) roles.push('Lieferant'); + return roles.join(', ') || '—'; +} + +// ============================================================ +// Import Tab — Lexware → CRM +// ============================================================ + +function ImportTab() { + const [searchTerm, setSearchTerm] = useState(''); + const [debouncedTerm, setDebouncedTerm] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + const inputRef = useRef(null); + + const importAsCompany = useImportLexwareAsCompany(); + const importAsContact = useImportLexwareAsContact(); + + useEffect(() => { + setTimeout(() => inputRef.current?.focus(), 100); + }, []); + + // Debounce + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedTerm(searchTerm.trim()); + }, 400); + return () => clearTimeout(timer); + }, [searchTerm]); + + const { data, isLoading } = useLexwareContactSearch( + { name: debouncedTerm }, + debouncedTerm.length >= 2, + ); + + const results: LexwareContact[] = data?.data ?? []; + const isImporting = importAsCompany.isPending || importAsContact.isPending; + + const handleImportAsCompany = (lexwareContactId: string, name: string) => { + importAsCompany.mutate( + { lexwareContactId }, + { + onSuccess: () => { + setSuccessMessage(`"${name}" als Unternehmen importiert`); + setTimeout(() => setSuccessMessage(''), 4000); + }, + }, + ); + }; + + const handleImportAsContact = (lexwareContactId: string, name: string) => { + importAsContact.mutate( + { lexwareContactId }, + { + onSuccess: () => { + setSuccessMessage(`"${name}" als Kontakt importiert`); + setTimeout(() => setSuccessMessage(''), 4000); + }, + }, + ); + }; + + return ( +
+

+ Lexware Office Kontakte durchsuchen und als Unternehmen oder Kontakt in + das CRM importieren. +

+ + setSearchTerm(e.target.value)} + /> + + {successMessage && ( +
+ + + + {successMessage} +
+ )} + + {(importAsCompany.isError || importAsContact.isError) && ( +
+ Import fehlgeschlagen:{' '} + {(importAsCompany.error ?? importAsContact.error)?.message ?? + 'Unbekannter Fehler'} +
+ )} + +
+ {debouncedTerm.length < 2 && ( +

Mindestens 2 Zeichen eingeben...

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

Suche in Lexware Office...

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

Keine Ergebnisse gefunden

+ )} + + {results.map((contact) => { + const name = lexwareDisplayName(contact); + const email = lexwareEmail(contact); + const address = lexwareAddress(contact); + const roles = lexwareRoles(contact); + + return ( +
+
+
{name}
+
+ {roles !== '—' && ( + {roles} + )} + {email && {email}} + {address && {address}} +
+
+
+ + +
+
+ ); + })} +
+
+ ); +} + +// ============================================================ +// Export Tab — CRM → Lexware +// ============================================================ + +function ExportTab() { + const [entityFilter, setEntityFilter] = useState<'companies' | 'contacts'>( + 'companies', + ); + const [successMessage, setSuccessMessage] = useState(''); + + const pushToLexware = usePushToLexware(); + + // Fetch all companies (page 1, big page size) + const companiesQuery = useCompanies({ + page: 1, + pageSize: 200, + sort: 'name', + order: 'asc', + }); + const contactsQuery = useContacts({ + page: 1, + pageSize: 200, + sort: 'lastName', + order: 'asc', + }); + + const companies: Company[] = companiesQuery.data?.data ?? []; + const contacts: Contact[] = contactsQuery.data?.data ?? []; + + // Split into linked / unlinked + const unlinkedCompanies = companies.filter((c) => !c.lexwareContactId); + const linkedCompanies = companies.filter((c) => !!c.lexwareContactId); + const unlinkedContacts = contacts.filter((c) => !c.lexwareContactId); + const linkedContacts = contacts.filter((c) => !!c.lexwareContactId); + + const handlePush = ( + entityType: 'company' | 'contact', + entityId: string, + name: string, + ) => { + pushToLexware.mutate( + { entityType, entityId }, + { + onSuccess: () => { + setSuccessMessage(`"${name}" nach Lexware exportiert`); + setTimeout(() => setSuccessMessage(''), 4000); + }, + }, + ); + }; + + const isExporting = pushToLexware.isPending; + + return ( +
+

+ CRM-Daten zu Lexware Office exportieren. Noch nicht verknüpfte Einträge + werden als neue Lexware-Kontakte angelegt. +

+ + {/* Filter Tabs */} +
+ + +
+ + {successMessage && ( +
+ + + + {successMessage} +
+ )} + + {pushToLexware.isError && ( +
+ Export fehlgeschlagen: {pushToLexware.error?.message ?? 'Unbekannter Fehler'} +
+ )} + + {/* Companies */} + {entityFilter === 'companies' && ( + <> + {companiesQuery.isLoading ? ( +

Unternehmen werden geladen...

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

Keine Unternehmen vorhanden

+ ) : ( + <> + {/* Unlinked first */} + {unlinkedCompanies.length > 0 && ( +
+

+ Noch nicht verknüpft ({unlinkedCompanies.length}) +

+
+ {unlinkedCompanies.map((c) => ( +
+
+ {c.name} + + {[c.email, c.city].filter(Boolean).join(' · ') || + 'Keine Details'} + +
+ +
+ ))} +
+
+ )} + + {/* Linked */} + {linkedCompanies.length > 0 && ( +
+

+ + Bereits verknüpft ({linkedCompanies.length}) +

+
+ {linkedCompanies.map((c) => ( +
+
+ {c.name} + + {c.lexwareSyncedAt + ? `Sync: ${new Date(c.lexwareSyncedAt).toLocaleDateString('de-DE')}` + : 'Verknüpft'} + {c.email ? ` · ${c.email}` : ''} + +
+ +
+ ))} +
+
+ )} + + )} + + )} + + {/* Contacts */} + {entityFilter === 'contacts' && ( + <> + {contactsQuery.isLoading ? ( +

Kontakte werden geladen...

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

Keine Kontakte vorhanden

+ ) : ( + <> + {/* Unlinked first */} + {unlinkedContacts.length > 0 && ( +
+

+ Noch nicht verknüpft ({unlinkedContacts.length}) +

+
+ {unlinkedContacts.map((c) => { + const name = + [c.firstName, c.lastName].filter(Boolean).join(' ') || + c.companyName || + '—'; + return ( +
+
+ {name} + + {[c.email, c.city, c.company?.name] + .filter(Boolean) + .join(' · ') || 'Keine Details'} + +
+ +
+ ); + })} +
+
+ )} + + {/* Linked */} + {linkedContacts.length > 0 && ( +
+

+ + Bereits verknüpft ({linkedContacts.length}) +

+
+ {linkedContacts.map((c) => { + const name = + [c.firstName, c.lastName].filter(Boolean).join(' ') || + c.companyName || + '—'; + return ( +
+
+ {name} + + {c.lexwareSyncedAt + ? `Sync: ${new Date(c.lexwareSyncedAt).toLocaleDateString('de-DE')}` + : 'Verknüpft'} + {c.email ? ` · ${c.email}` : ''} + +
+ +
+ ); + })} +
+
+ )} + + )} + + )} +
+ ); +} + +// ============================================================ +// LexwareSyncPage — Main +// ============================================================ + +type TabKey = 'import' | 'export'; + +export function LexwareSyncPage() { + const { user } = useAuth(); + const { isModuleEnabled } = useCrmSettings(); + const [activeTab, setActiveTab] = useState('import'); + + // Zugriffskontrolle + if ( + user?.role !== 'PLATFORM_ADMIN' && + user?.role !== 'TENANT_ADMIN' + ) { + return ; + } + + if (!isModuleEnabled('lexware')) { + return ; + } + + return ( +
+ {/* Zurück */} + + + + + Zurück zu CRM Einstellungen + + + {/* Header */} +
+

+ LX + Lexware Office Synchronisation +

+
+ + {/* Tab Bar */} +
+ + +
+ + {/* Tab Content */} +
+ {activeTab === 'import' && } + {activeTab === 'export' && } +
+
+ ); +} diff --git a/packages/frontend/src/crm/settings/CrmSettingsPage.tsx b/packages/frontend/src/crm/settings/CrmSettingsPage.tsx index 113235f..efb44c4 100644 --- a/packages/frontend/src/crm/settings/CrmSettingsPage.tsx +++ b/packages/frontend/src/crm/settings/CrmSettingsPage.tsx @@ -224,6 +224,69 @@ export function CrmSettingsPage() { )} + {/* Lexware Synchronisation — nur wenn Modul aktiv */} + {settings.modules.lexware?.enabled && ( +
+

+ + LX + + Lexware Office Synchronisation +

+

+ Kontakte aus Lexware Office importieren oder CRM-Daten nach Lexware + exportieren. +

+ + + + + + Import / Export öffnen + +
+ )} + {/* Platzhalter für zukünftige Einstellungen */}

Weitere Einstellungen

diff --git a/packages/frontend/src/shell/App.tsx b/packages/frontend/src/shell/App.tsx index ac76299..0cb92db 100644 --- a/packages/frontend/src/shell/App.tsx +++ b/packages/frontend/src/shell/App.tsx @@ -20,6 +20,7 @@ import { CompaniesPage } from '../crm/companies/CompaniesPage'; import { CompanyDetailPage } from '../crm/companies/CompanyDetailPage'; import { CrmSettingsProvider, CrmModuleGuard } from '../crm/settings/CrmSettingsContext'; import { CrmSettingsPage } from '../crm/settings/CrmSettingsPage'; +import { LexwareSyncPage } from '../crm/lexware/LexwareSyncPage'; function PrivateRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuth(); @@ -68,6 +69,7 @@ export function App() { } /> } /> } /> + } /> {/* Admin-Bereich mit eigenem Layout (Top-Tabs) */} }> } />