471 lines
21 KiB
TypeScript
471 lines
21 KiB
TypeScript
/**
|
||
* 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, useRef } 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 {
|
||
fetchAccountingConnectors,
|
||
fetchAccountingConfig,
|
||
saveAccountingConfig,
|
||
deleteAccountingConfig,
|
||
testAccountingConnection,
|
||
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<AccountingConnectorInfo[]>([]);
|
||
const [existingConfig, setExistingConfig] = useState<AccountingConfig | null>(null);
|
||
const [selectedType, setSelectedType] = useState<string>('');
|
||
const [displayLabel, setDisplayLabel] = useState('');
|
||
const [configValues, setConfigValues] = useState<Record<string, string>>({});
|
||
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<Record<string, any> | null>(null);
|
||
const [importStatus, setImportStatus] = useState<Record<string, any> | null>(null);
|
||
const [clearingCache, setClearingCache] = useState(false);
|
||
const [dateFrom, setDateFrom] = useState('');
|
||
const [dateTo, setDateTo] = useState('');
|
||
const mountedRef = useRef(true);
|
||
const { confirm, ConfirmDialog } = useConfirm();
|
||
|
||
useEffect(() => {
|
||
if (!importDone) return;
|
||
const importResetTimer = setTimeout(() => { setImporting(false); setImportDone(false); }, 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<string, string>);
|
||
}
|
||
}
|
||
} catch (err: any) {
|
||
console.error('Failed to load accounting settings:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, [instanceId, request]);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
return () => { mountedRef.current = false; };
|
||
}, [loadData]);
|
||
|
||
const _loadImportStatus = useCallback(async () => {
|
||
if (!instanceId) return;
|
||
try {
|
||
const res = await request({ url: `/api/trustee/${instanceId}/accounting/import-status`, method: 'get' });
|
||
if (mountedRef.current) setImportStatus(res.data);
|
||
} catch { /* ignore */ }
|
||
}, [instanceId, request]);
|
||
|
||
useEffect(() => {
|
||
if (existingConfig?.configured) _loadImportStatus();
|
||
}, [existingConfig, _loadImportStatus]);
|
||
|
||
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 <div className={styles.loading}>{t('Buchhaltungseinstellungen werden geladen…')}</div>;
|
||
}
|
||
|
||
return (
|
||
<div className={styles.listView}>
|
||
<div className={styles.expenseImportSection}>
|
||
<h3 className={styles.sectionTitle}>{t('Buchhaltungssystem-Anbindung')}</h3>
|
||
<p className={styles.sectionDescription}>
|
||
{t('Verbinden Sie ein Buchhaltungssystem, um Buchungen aus dieser Trustee-Instanz automatisch zu synchronisieren.')}
|
||
</p>
|
||
|
||
{existingConfig?.configured && (
|
||
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
|
||
<strong>{t('Verbunden:')}</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
||
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
||
<> · {t('Letzter Sync:')} {existingConfig.lastSyncStatus}</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{existingConfig?.configured && (existingConfig.lastSyncAt != null || existingConfig.lastSyncStatus != null) && (
|
||
<div className={styles.setupStep} style={{ marginTop: 0, marginBottom: '1rem' }}>
|
||
<div className={styles.stepNumber} style={{ visibility: 'hidden' }}>0</div>
|
||
<div className={styles.stepContent}>
|
||
<h4 style={{ marginTop: 0 }}>{t('Syncstatus Fehlerprotokoll')}</h4>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||
{existingConfig.lastSyncAt != null && (
|
||
<div style={{ fontSize: '0.9rem' }}>
|
||
<strong>{t('Letzter Sync:')}</strong>{' '}
|
||
{new Date(existingConfig.lastSyncAt * 1000).toLocaleString()}
|
||
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
||
<> · {t('Status:')} {existingConfig.lastSyncStatus}</>
|
||
)}
|
||
</div>
|
||
)}
|
||
{existingConfig.lastSyncStatus === 'error' && (existingConfig.lastSyncErrorMessage ?? '').trim() !== '' && (
|
||
<div className={styles.errorMessage} style={{ marginTop: '0.25rem', padding: '0.75rem' }}>
|
||
{existingConfig.lastSyncErrorMessage}
|
||
</div>
|
||
)}
|
||
{existingConfig.lastSyncStatus === 'error' && (!existingConfig.lastSyncErrorMessage || existingConfig.lastSyncErrorMessage.trim() === '') && (
|
||
<div className={styles.errorMessage} style={{ marginTop: '0.25rem', padding: '0.75rem' }}>
|
||
Der letzte Sync ist fehlgeschlagen. Details pro Position finden Sie unter Positionen (Spalte Sync-Status).
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 1: Select system */}
|
||
<div className={styles.setupStep}>
|
||
<div className={styles.stepNumber}>1</div>
|
||
<div className={styles.stepContent}>
|
||
<h4>{t('Buchhaltungssystem')}</h4>
|
||
<select
|
||
className={styles.folderSelect}
|
||
value={selectedType}
|
||
onChange={e => handleTypeChange(e.target.value)}
|
||
>
|
||
<option value="">{t('System auswählen…')}</option>
|
||
{connectors.map(c => (
|
||
<option key={c.connectorType} value={c.connectorType}>
|
||
{c.label || c.connectorType}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Step 2: Credentials */}
|
||
{selectedConnector && (
|
||
<div className={styles.setupStep}>
|
||
<div className={styles.stepNumber}>2</div>
|
||
<div className={styles.stepContent}>
|
||
<h4>{t('Zugangsdaten')}</h4>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||
<div>
|
||
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.85rem' }}>
|
||
{t('Anzeigename')}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
className={styles.folderSelect}
|
||
value={displayLabel}
|
||
onChange={e => setDisplayLabel(e.target.value)}
|
||
placeholder={t('z. B. Run My Accounts – Muster AG')}
|
||
/>
|
||
</div>
|
||
{selectedConnector.configFields.map(field => (
|
||
<div key={field.key}>
|
||
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.85rem' }}>
|
||
{field.label || field.key}
|
||
{field.required && <span style={{ color: 'var(--error-color, #dc2626)' }}> *</span>}
|
||
</label>
|
||
<input
|
||
type={field.secret ? 'password' : 'text'}
|
||
className={styles.folderSelect}
|
||
value={configValues[field.key] || ''}
|
||
onChange={e => handleConfigChange(field.key, e.target.value)}
|
||
placeholder={field.placeholder || ''}
|
||
autoComplete={field.secret ? 'new-password' : 'off'}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 3: Save & Test */}
|
||
{selectedConnector && (
|
||
<div className={styles.setupStep}>
|
||
<div className={styles.stepNumber}>3</div>
|
||
<div className={styles.stepContent}>
|
||
<h4>{t('Test speichern')}</h4>
|
||
{testResult && (
|
||
<div className={testResult.success ? styles.successMessage : styles.errorMessage} style={{ marginBottom: '0.75rem' }}>
|
||
{testResult.success ? t('Verbindung erfolgreich!') : t('Verbindung fehlgeschlagen: {message}', { message: testResult.message || t('Unbekannter Fehler') })}
|
||
</div>
|
||
)}
|
||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||
<button
|
||
className={styles.primaryButton}
|
||
onClick={handleSave}
|
||
disabled={saving}
|
||
>
|
||
{saving ? t('Speichern…') : t('Konfiguration speichern')}
|
||
</button>
|
||
{existingConfig?.configured && (
|
||
<button
|
||
className={styles.secondaryButton}
|
||
onClick={handleTestConnection}
|
||
disabled={testing}
|
||
>
|
||
{testing ? t('Teste…') : t('Verbindung testen')}
|
||
</button>
|
||
)}
|
||
{existingConfig?.configured && (
|
||
<button
|
||
className={styles.secondaryButton}
|
||
onClick={handleRemove}
|
||
disabled={saving}
|
||
style={{ color: 'var(--error-color, #dc2626)' }}
|
||
>
|
||
{t('Anbindung entfernen')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 4: Import Accounting Data */}
|
||
{existingConfig?.configured && (
|
||
<div className={styles.setupStep}>
|
||
<div className={styles.stepNumber}>4</div>
|
||
<div className={styles.stepContent}>
|
||
<h4>{t('Buchhaltungsdaten importieren')}</h4>
|
||
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '0.75rem' }}>
|
||
{t('Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.')}
|
||
</p>
|
||
|
||
<div style={{ display: 'flex', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>{t('Von (optional)')}</label>
|
||
<input type="date" className={styles.folderSelect} value={dateFrom} onChange={e => setDateFrom(e.target.value)} style={{ width: '160px' }} />
|
||
</div>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>{t('Bis (optional)')}</label>
|
||
<input type="date" className={styles.folderSelect} value={dateTo} onChange={e => setDateTo(e.target.value)} style={{ width: '160px' }} />
|
||
</div>
|
||
</div>
|
||
<div style={{ display: 'flex', gap: '0.4rem', marginBottom: '0.75rem', flexWrap: 'wrap' }}>
|
||
{[
|
||
{ 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 => (
|
||
<button
|
||
key={s.label}
|
||
type="button"
|
||
className={styles.secondaryButton}
|
||
style={{ fontSize: '0.75rem', padding: '0.25rem 0.6rem', minWidth: 0 }}
|
||
onClick={() => { setDateFrom(s.from); setDateTo(s.to); }}
|
||
>
|
||
{s.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||
<button
|
||
className={styles.primaryButton}
|
||
disabled={importing}
|
||
onClick={async () => {
|
||
if (!instanceId) return;
|
||
setImporting(true);
|
||
setImportResult(null);
|
||
try {
|
||
const body: Record<string, string> = {};
|
||
if (dateFrom) body.dateFrom = dateFrom;
|
||
if (dateTo) body.dateTo = dateTo;
|
||
const res = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
|
||
if (mountedRef.current) {
|
||
setImportResult(res.data);
|
||
if (res.data.errors?.length) {
|
||
showError(t('Import teilweise fehlgeschlagen'), res.data.errors.join('; '));
|
||
} else {
|
||
showSuccess(t('Import abgeschlossen'),
|
||
t('{konten} Konten, {buchungen} Buchungen, {kontakte} Kontakte, {salden} Salden importiert.', {
|
||
konten: String(res.data.accounts || 0),
|
||
buchungen: String(res.data.journalEntries || 0),
|
||
kontakte: String(res.data.contacts || 0),
|
||
salden: String(res.data.accountBalances || 0),
|
||
}));
|
||
}
|
||
_loadImportStatus();
|
||
}
|
||
} catch (err: any) {
|
||
showError(t('Import fehlgeschlagen'), err.response?.data?.detail || err.message || t('Unbekannter Fehler'));
|
||
} finally {
|
||
setImportDone(true);
|
||
}
|
||
}}
|
||
>
|
||
{importing ? t('Importiere…') : t('Daten jetzt einlesen')}
|
||
</button>
|
||
<button
|
||
className={styles.secondaryButton}
|
||
disabled={clearingCache}
|
||
onClick={async () => {
|
||
if (!instanceId) return;
|
||
setClearingCache(true);
|
||
try {
|
||
const res = await request({ url: `/api/trustee/${instanceId}/accounting/clear-cache`, method: 'post' });
|
||
showSuccess(t('Cache geleert'), t('{n} gecachte Abfragen entfernt. Die nächste KI-Abfrage liest frische Daten.', { n: String(res.data?.cleared ?? 0) }));
|
||
} catch (err: any) {
|
||
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Cache konnte nicht geleert werden.'));
|
||
} finally {
|
||
setClearingCache(false);
|
||
}
|
||
}}
|
||
>
|
||
{clearingCache ? t('Leere…') : t('KI-Cache leeren')}
|
||
</button>
|
||
</div>
|
||
|
||
{importResult && !importResult.errors?.length && (
|
||
<div className={styles.successMessage} style={{ marginTop: '0.75rem' }}>
|
||
{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),
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{importStatus && (importStatus.accounts > 0 || importStatus.journalEntries > 0) && (
|
||
<div style={{ marginTop: '0.75rem', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
||
<strong>{t('Aktueller Datenbestand:')}</strong>{' '}
|
||
{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()}</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<ConfirmDialog />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TrusteeAccountingSettingsView;
|