/** * TrusteeAccountingSettingsView * * Settings page for configuring the accounting system integration. * Allows selecting a connector (RMA, Bexio, Abacus), entering credentials, * testing the connection, and removing the integration. */ import React, { useState, useEffect, useCallback } from 'react'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useApiRequest } from '../../../hooks/useApi'; import { useToast } from '../../../contexts/ToastContext'; import { useConfirm } from '../../../hooks/useConfirm'; import { useLanguage } from '../../../providers/language/LanguageContext'; import { useBackgroundJob } from '../../../hooks/useBackgroundJob'; import { fetchAccountingConnectors, fetchAccountingConfig, saveAccountingConfig, deleteAccountingConfig, testAccountingConnection, exportAccountingData, type AccountingConnectorInfo, type AccountingConfig, } from '../../../api/trusteeApi'; import styles from './TrusteeViews.module.css'; export const TrusteeAccountingSettingsView: React.FC = () => { const { t } = useLanguage(); const { instanceId } = useCurrentInstance(); const { request } = useApiRequest(); const { showSuccess, showError } = useToast(); const [connectors, setConnectors] = useState([]); const [existingConfig, setExistingConfig] = useState(null); const [selectedType, setSelectedType] = useState(''); const [displayLabel, setDisplayLabel] = useState(''); const [configValues, setConfigValues] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message?: string } | null>(null); const [importing, setImporting] = useState(false); const [importDone, setImportDone] = useState(false); const [importResult, setImportResult] = useState | null>(null); const [importStatus, setImportStatus] = useState | null>(null); const [importJobId, setImportJobId] = useState(null); const [clearingCache, setClearingCache] = useState(false); const [exporting, setExporting] = useState(false); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); const { confirm, ConfirmDialog } = useConfirm(); useEffect(() => { if (!importDone) return; const importResetTimer = setTimeout(() => { setImporting(false); setImportDone(false); setImportJobId(null); }, 5000); return () => clearTimeout(importResetTimer); }, [importDone]); const loadData = useCallback(async () => { if (!instanceId) return; setLoading(true); try { const [availableConnectors, config] = await Promise.all([ fetchAccountingConnectors(request, instanceId), fetchAccountingConfig(request, instanceId), ]); setConnectors(availableConnectors || []); setExistingConfig(config); if (config?.configured && config.connectorType) { setSelectedType(config.connectorType); setDisplayLabel(config.displayLabel || ''); if (config.configMasked && typeof config.configMasked === 'object') { setConfigValues(config.configMasked as Record); } } } catch (err: any) { console.error('Failed to load accounting settings:', err); } finally { setLoading(false); } }, [instanceId, request]); useEffect(() => { loadData(); }, [loadData]); const _loadImportStatus = useCallback(async () => { if (!instanceId) return; try { const data = await request({ url: `/api/trustee/${instanceId}/accounting/import-status`, method: 'get' }); setImportStatus(data); } catch (err) { console.error('[Trustee] import-status fetch failed:', err); } }, [instanceId, request]); useEffect(() => { if (existingConfig?.configured) _loadImportStatus(); }, [existingConfig, _loadImportStatus]); const { job: importJob } = useBackgroundJob(importJobId, { enabled: !!importJobId, pollMs: 2000, onSuccess: (j) => { const summary = (j.result || {}) as Record; setImportResult(summary); const errs: string[] = Array.isArray(summary.errors) ? summary.errors : []; if (errs.length) { showError(t('Import teilweise fehlgeschlagen'), errs.join('; ')); } else { showSuccess(t('Import abgeschlossen'), t('{konten} Konten, {buchungen} Buchungen, {kontakte} Kontakte, {salden} Salden importiert.', { konten: String(summary.accounts || 0), buchungen: String(summary.journalEntries || 0), kontakte: String(summary.contacts || 0), salden: String(summary.accountBalances || 0), })); } _loadImportStatus(); void loadData(); setImportDone(true); }, onError: (j) => { showError(t('Import fehlgeschlagen'), j.errorMessage || t('Unbekannter Fehler')); setImportDone(true); }, }); const _getSelectedConnector = (): AccountingConnectorInfo | undefined => { return connectors.find(c => c.connectorType === selectedType); }; const handleTypeChange = (newType: string) => { setSelectedType(newType); setConfigValues({}); setTestResult(null); }; const handleConfigChange = (key: string, value: string) => { setConfigValues(prev => ({ ...prev, [key]: value })); }; const handleSave = async () => { if (!instanceId || !selectedType) return; setSaving(true); try { await saveAccountingConfig(request, instanceId, { connectorType: selectedType, displayLabel, config: configValues, }); showSuccess(t('Gespeichert'), t('Die Buchhaltungskonfiguration wurde erfolgreich gespeichert.')); await loadData(); } catch (err: any) { showError(t('Fehler'), err.response?.data?.detail || err.message || t('Speichern der Konfiguration fehlgeschlagen.')); } finally { setSaving(false); } }; const handleTestConnection = async () => { if (!instanceId) return; setTesting(true); setTestResult(null); try { const result = await testAccountingConnection(request, instanceId); setTestResult({ success: result.success, message: result.errorMessage }); if (result.success) { showSuccess(t('Verbindung OK'), t('Verbindung zum Buchhaltungssystem erfolgreich.')); } else { showError(t('Verbindung fehlgeschlagen'), result.errorMessage || t('Keine Verbindung möglich.')); } } catch (err: any) { const msg = err.response?.data?.detail || err.message || t('Verbindungstest fehlgeschlagen.'); setTestResult({ success: false, message: msg }); showError(t('Fehler'), msg); } finally { setTesting(false); } }; const handleRemove = async () => { if (!instanceId) return; const ok = await confirm(t('Buchhaltungsanbindung entfernen? Synchronisierte Daten bleiben erhalten.'), { title: t('Anbindung entfernen'), confirmLabel: t('Entfernen'), variant: 'danger', }); if (!ok) return; setSaving(true); try { await deleteAccountingConfig(request, instanceId); showSuccess(t('Entfernt'), t('Buchhaltungsanbindung wurde entfernt.')); setSelectedType(''); setDisplayLabel(''); setConfigValues({}); setTestResult(null); await loadData(); } catch (err: any) { showError(t('Fehler'), err.message || t('Entfernen der Konfiguration fehlgeschlagen.')); } finally { setSaving(false); } }; const selectedConnector = _getSelectedConnector(); if (loading) { return
{t('Buchhaltungseinstellungen werden geladen…')}
; } return (

{t('Buchhaltungssystem-Anbindung')}

{t('Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.')}

{existingConfig?.configured && (
{t('Verbunden:')} {existingConfig.displayLabel || existingConfig.connectorType}
)} {existingConfig?.configured && existingConfig.lastSyncStatus === 'error' && (
0

{t('Letzter Sync fehlgeschlagen')}

{existingConfig.lastSyncAt != null && (
{t('Zeitpunkt:')}{' '} {new Date(existingConfig.lastSyncAt * 1000).toLocaleString()}
)}
{(existingConfig.lastSyncErrorMessage ?? '').trim() !== '' ? existingConfig.lastSyncErrorMessage : t('Der letzte Sync ist fehlgeschlagen. Details pro Position finden Sie unter Positionen (Spalte Sync-Status).')}
)} {/* Step 1: Select system */}
1

{t('Buchhaltungssystem')}

{/* Step 2: Credentials */} {selectedConnector && (
2

{t('Zugangsdaten')}

setDisplayLabel(e.target.value)} placeholder={t('z. B. Run My Accounts – Muster AG')} />
{selectedConnector.configFields.map(field => { const datalistId = field.suggestions && field.suggestions.length > 0 ? `dl-${selectedConnector.connectorType}-${field.key}` : undefined; return (
handleConfigChange(field.key, e.target.value)} placeholder={field.placeholder || ''} autoComplete={field.secret ? 'new-password' : 'off'} list={datalistId} /> {datalistId && ( {field.suggestions!.map(s => ( )}
); })}
)} {/* Step 3: Save & Test */} {selectedConnector && (
3

{t('Test speichern')}

{testResult && (
{testResult.success ? t('Verbindung erfolgreich!') : t('Verbindung fehlgeschlagen: {message}', { message: testResult.message || t('Unbekannter Fehler') })}
)}
{existingConfig?.configured && ( )} {existingConfig?.configured && ( )}
)} {/* Step 4: Import Accounting Data */} {existingConfig?.configured && (
4

{t('Buchhaltungsdaten importieren')}

{t('Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.')}

{(() => { const lastSyncAt = importStatus?.lastSyncAt as number | null | undefined; const winFrom = importStatus?.lastSyncDateFrom as string | undefined; const winTo = importStatus?.lastSyncDateTo as string | undefined; const counts = (importStatus?.lastSyncCounts || {}) as Record; const timeWindow = winFrom && winTo ? t('{from} bis {to}', { from: winFrom, to: winTo }) : winFrom ? t('ab {from}', { from: winFrom }) : winTo ? t('bis {to}', { to: winTo }) : null; return (
{lastSyncAt ? ( <>
{t('Letzter Import:')} {new Date(lastSyncAt * 1000).toLocaleString()} {timeWindow && ( <> {' '}· {t('Zeitfenster:')} {timeWindow} )}
{t('{konten} Konten, {buchungen} Buchungen ({zeilen} Zeilen), {kontakte} Kontakte, {salden} Salden', { konten: String(counts.accounts ?? 0), buchungen: String(counts.journalEntries ?? 0), zeilen: String(counts.journalLines ?? 0), kontakte: String(counts.contacts ?? 0), salden: String(counts.accountBalances ?? 0), })}
) : (
{t('Noch kein Import durchgeführt. Wähle unten ein Zeitfenster und klicke auf «Daten jetzt einlesen».')}
)}
); })()}
setDateFrom(e.target.value)} style={{ width: '160px' }} />
setDateTo(e.target.value)} style={{ width: '160px' }} />
{[ { label: t('Laufendes Jahr'), from: `${new Date().getFullYear()}-01-01`, to: new Date().toISOString().slice(0, 10) }, { label: t('Letztes Jahr'), from: `${new Date().getFullYear() - 1}-01-01`, to: `${new Date().getFullYear() - 1}-12-31`, }, { label: t('Letzter Monat'), from: (() => { const d = new Date(); d.setDate(1); d.setMonth(d.getMonth() - 1); return d.toISOString().slice(0, 10); })(), to: (() => { const d = new Date(); d.setDate(0); return d.toISOString().slice(0, 10); })(), }, ].map(s => ( ))}
{importing && importJob && (
{importJob.progress}% {importJob.progressMessage ? `· ${importJob.progressMessage}` : ''}
)}
{importResult && !importResult.errors?.length && (
{t('Import abgeschlossen in {sek}s:', { sek: String(importResult.durationSeconds) })}{' '} {t('{konten} Konten, {buchungen} Buchungen ({zeilen} Zeilen), {kontakte} Kontakte, {salden} Salden', { konten: String(importResult.accounts), buchungen: String(importResult.journalEntries), zeilen: String(importResult.journalLines), kontakte: String(importResult.contacts), salden: String(importResult.accountBalances), })}
)} {importStatus && (importStatus.accounts > 0 || importStatus.journalEntries > 0) && (
{t('Aktueller Datenbestand:')}{' '} {t('{konten} Konten, {buchungen} Buchungen, {zeilen} Zeilen, {kontakte} Kontakte, {salden} Salden', { konten: String(importStatus.accounts), buchungen: String(importStatus.journalEntries), zeilen: String(importStatus.journalLines), kontakte: String(importStatus.contacts), salden: String(importStatus.accountBalances), })} {importStatus.lastSyncAt && ( <> · {t('Letzter Import:')} {new Date(importStatus.lastSyncAt * 1000).toLocaleString()} )}
)}
)}
); }; export default TrusteeAccountingSettingsView;