frontend_nyla/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx
2026-04-20 17:51:07 +02:00

588 lines
26 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 } 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<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 [importJobId, setImportJobId] = useState<string | null>(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<string, string>);
}
}
} 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<string, any>;
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 <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}
</div>
)}
{existingConfig?.configured && existingConfig.lastSyncStatus === 'error' && (
<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('Letzter Sync fehlgeschlagen')}</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{existingConfig.lastSyncAt != null && (
<div style={{ fontSize: '0.9rem' }}>
<strong>{t('Zeitpunkt:')}</strong>{' '}
{new Date(existingConfig.lastSyncAt * 1000).toLocaleString()}
</div>
)}
<div className={styles.errorMessage} style={{ marginTop: '0.25rem', padding: '0.75rem' }}>
{(existingConfig.lastSyncErrorMessage ?? '').trim() !== ''
? existingConfig.lastSyncErrorMessage
: t('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 => {
const datalistId = field.suggestions && field.suggestions.length > 0
? `dl-${selectedConnector.connectorType}-${field.key}`
: undefined;
return (
<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'}
list={datalistId}
/>
{datalistId && (
<datalist id={datalistId}>
{field.suggestions!.map(s => (
<option key={s} value={s} />
))}
</datalist>
)}
</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>
{(() => {
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<string, number>;
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 (
<div style={{
fontSize: '0.8rem',
color: 'var(--text-secondary)',
background: 'var(--surface-color, #f5f5f5)',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: '6px',
padding: '0.5rem 0.75rem',
marginBottom: '0.75rem',
}}>
{lastSyncAt ? (
<>
<div>
<strong>{t('Letzter Import:')}</strong> {new Date(lastSyncAt * 1000).toLocaleString()}
{timeWindow && (
<> {' '}&middot; <strong>{t('Zeitfenster:')}</strong> {timeWindow}</>
)}
</div>
<div style={{ marginTop: '0.2rem' }}>
{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),
})}
</div>
</>
) : (
<div>
<em>{t('Noch kein Import durchgeführt. Wähle unten ein Zeitfenster und klicke auf «Daten jetzt einlesen».')}</em>
</div>
)}
</div>
);
})()}
<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);
setImportJobId(null);
try {
const body: Record<string, string> = {};
if (dateFrom) body.dateFrom = dateFrom;
if (dateTo) body.dateTo = dateTo;
const result = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
const newJobId: string | undefined = result?.jobId;
if (newJobId) {
setImportJobId(newJobId);
} else {
showError(t('Import fehlgeschlagen'), t('Kein jobId vom Server erhalten'));
setImportDone(true);
}
} catch (err: any) {
showError(t('Import fehlgeschlagen'), err.response?.data?.detail || err.message || t('Unbekannter Fehler'));
setImportDone(true);
}
}}
>
{importing
? (importJob?.progressMessage || t('Importiere…'))
: t('Daten jetzt einlesen')}
</button>
{importing && importJob && (
<div style={{ flex: '1 1 100%', marginTop: '0.5rem' }}>
<div style={{
width: '100%', height: '6px', background: 'var(--surface-color, #eee)',
borderRadius: '3px', overflow: 'hidden',
}}>
<div style={{
width: `${Math.max(2, importJob.progress || 0)}%`,
height: '100%',
background: 'var(--primary-color, #4CAF50)',
transition: 'width 0.4s ease',
}} />
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
{importJob.progress}% {importJob.progressMessage ? `· ${importJob.progressMessage}` : ''}
</div>
</div>
)}
<button
className={styles.secondaryButton}
disabled={clearingCache}
onClick={async () => {
if (!instanceId) return;
setClearingCache(true);
try {
const result = 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(result?.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>
<button
className={styles.secondaryButton}
disabled={exporting}
onClick={async () => {
if (!instanceId) return;
setExporting(true);
try {
await exportAccountingData(request, instanceId);
showSuccess(t('Export gestartet'), t('Die Daten werden als JSON-Datei heruntergeladen.'));
} catch (err: any) {
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Export fehlgeschlagen.'));
} finally {
setExporting(false);
}
}}
>
{exporting ? t('Exportiere…') : t('Alle Daten exportieren (JSON)')}
</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;