frontend_nyla/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx
2026-04-11 19:44:52 +02:00

471 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 !== '' && (
<> &middot; {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 !== '' && (
<> &middot; {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 && (
<> &middot; {t('Letzter Import:')} {new Date(importStatus.lastSyncAt * 1000).toLocaleString()}</>
)}
</div>
)}
</div>
</div>
)}
</div>
<ConfirmDialog />
</div>
);
};
export default TrusteeAccountingSettingsView;