588 lines
26 KiB
TypeScript
588 lines
26 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 } 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 && (
|
||
<> {' '}· <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 && (
|
||
<> · {t('Letzter Import:')} {new Date(importStatus.lastSyncAt * 1000).toLocaleString()}</>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<ConfirmDialog />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default TrusteeAccountingSettingsView;
|