436 lines
18 KiB
TypeScript
436 lines
18 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 {
|
|
fetchAccountingConnectors,
|
|
fetchAccountingConfig,
|
|
saveAccountingConfig,
|
|
deleteAccountingConfig,
|
|
testAccountingConnection,
|
|
type AccountingConnectorInfo,
|
|
type AccountingConfig,
|
|
} from '../../../api/trusteeApi';
|
|
import styles from './TrusteeViews.module.css';
|
|
|
|
export const TrusteeAccountingSettingsView: React.FC = () => {
|
|
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 [dateFrom, setDateFrom] = useState('');
|
|
const [dateTo, setDateTo] = useState('');
|
|
const mountedRef = useRef(true);
|
|
const { confirm, ConfirmDialog } = useConfirm();
|
|
|
|
useEffect(() => {
|
|
if (!importDone) return;
|
|
const t = setTimeout(() => { setImporting(false); setImportDone(false); }, 5000);
|
|
return () => clearTimeout(t);
|
|
}, [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('Saved', 'Accounting configuration saved successfully.');
|
|
await loadData();
|
|
} catch (err: any) {
|
|
showError('Error', err.response?.data?.detail || err.message || 'Failed to save configuration.');
|
|
} 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('Connection OK', 'Successfully connected to the accounting system.');
|
|
} else {
|
|
showError('Connection Failed', result.errorMessage || 'Could not connect.');
|
|
}
|
|
} catch (err: any) {
|
|
const msg = err.response?.data?.detail || err.message || 'Connection test failed.';
|
|
setTestResult({ success: false, message: msg });
|
|
showError('Error', msg);
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
};
|
|
|
|
const handleRemove = async () => {
|
|
if (!instanceId) return;
|
|
const ok = await confirm('Remove the accounting integration? This does not delete synced data.', {
|
|
title: 'Remove Integration',
|
|
confirmLabel: 'Remove',
|
|
variant: 'danger',
|
|
});
|
|
if (!ok) return;
|
|
setSaving(true);
|
|
try {
|
|
await deleteAccountingConfig(request, instanceId);
|
|
showSuccess('Removed', 'Accounting integration removed.');
|
|
setSelectedType('');
|
|
setDisplayLabel('');
|
|
setConfigValues({});
|
|
setTestResult(null);
|
|
await loadData();
|
|
} catch (err: any) {
|
|
showError('Error', err.message || 'Failed to remove configuration.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const selectedConnector = _getSelectedConnector();
|
|
|
|
if (loading) {
|
|
return <div className={styles.loading}>Loading accounting settings...</div>;
|
|
}
|
|
|
|
return (
|
|
<div className={styles.listView}>
|
|
<div className={styles.expenseImportSection}>
|
|
<h3 className={styles.sectionTitle}>Accounting System Integration</h3>
|
|
<p className={styles.sectionDescription}>
|
|
Connect an accounting system to automatically sync bookings from this Trustee instance.
|
|
</p>
|
|
|
|
{existingConfig?.configured && (
|
|
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
|
|
<strong>Connected:</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
|
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
|
<> · Last 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 }}>Sync-Status / Fehlerprotokoll</h4>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
|
{existingConfig.lastSyncAt != null && (
|
|
<div style={{ fontSize: '0.9rem' }}>
|
|
<strong>Letzter Sync:</strong>{' '}
|
|
{new Date(existingConfig.lastSyncAt * 1000).toLocaleString()}
|
|
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
|
<> · 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>Accounting System</h4>
|
|
<select
|
|
className={styles.folderSelect}
|
|
value={selectedType}
|
|
onChange={e => handleTypeChange(e.target.value)}
|
|
>
|
|
<option value="">Select a system...</option>
|
|
{connectors.map(c => (
|
|
<option key={c.connectorType} value={c.connectorType}>
|
|
{c.label?.de || c.label?.en || 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>Credentials</h4>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
|
<div>
|
|
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.85rem' }}>
|
|
Display Label
|
|
</label>
|
|
<input
|
|
type="text"
|
|
className={styles.folderSelect}
|
|
value={displayLabel}
|
|
onChange={e => setDisplayLabel(e.target.value)}
|
|
placeholder="e.g. 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?.de || field.label?.en || 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>Save & Test</h4>
|
|
{testResult && (
|
|
<div className={testResult.success ? styles.successMessage : styles.errorMessage} style={{ marginBottom: '0.75rem' }}>
|
|
{testResult.success ? 'Connection successful!' : `Connection failed: ${testResult.message || 'Unknown error'}`}
|
|
</div>
|
|
)}
|
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
|
<button
|
|
className={styles.primaryButton}
|
|
onClick={handleSave}
|
|
disabled={saving}
|
|
>
|
|
{saving ? 'Saving...' : 'Save Configuration'}
|
|
</button>
|
|
{existingConfig?.configured && (
|
|
<button
|
|
className={styles.secondaryButton}
|
|
onClick={handleTestConnection}
|
|
disabled={testing}
|
|
>
|
|
{testing ? 'Testing...' : 'Test Connection'}
|
|
</button>
|
|
)}
|
|
{existingConfig?.configured && (
|
|
<button
|
|
className={styles.secondaryButton}
|
|
onClick={handleRemove}
|
|
disabled={saving}
|
|
style={{ color: 'var(--error-color, #dc2626)' }}
|
|
>
|
|
Remove Integration
|
|
</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>Buchhaltungsdaten importieren</h4>
|
|
<p style={{ fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '0.75rem' }}>
|
|
Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen.
|
|
Diese Daten stehen anschliessend im AI Workspace fuer Analysen zur Verfuegung.
|
|
</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' }}>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' }}>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: 'YTD', from: `${new Date().getFullYear()}-01-01`, to: new Date().toISOString().slice(0, 10) },
|
|
{
|
|
label: 'Letztes Jahr',
|
|
from: `${new Date().getFullYear() - 1}-01-01`,
|
|
to: `${new Date().getFullYear() - 1}-12-31`,
|
|
},
|
|
{
|
|
label: '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>
|
|
|
|
<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('Import teilweise fehlgeschlagen', res.data.errors.join('; '));
|
|
} else {
|
|
showSuccess('Import abgeschlossen',
|
|
`${res.data.accounts || 0} Konten, ${res.data.journalEntries || 0} Buchungen, ` +
|
|
`${res.data.contacts || 0} Kontakte, ${res.data.accountBalances || 0} Salden importiert.`);
|
|
}
|
|
_loadImportStatus();
|
|
}
|
|
} catch (err: any) {
|
|
showError('Import fehlgeschlagen', err.response?.data?.detail || err.message || 'Unbekannter Fehler');
|
|
} finally {
|
|
setImportDone(true);
|
|
}
|
|
}}
|
|
>
|
|
{importing ? 'Importiere...' : 'Daten jetzt einlesen'}
|
|
</button>
|
|
|
|
{importResult && !importResult.errors?.length && (
|
|
<div className={styles.successMessage} style={{ marginTop: '0.75rem' }}>
|
|
Import abgeschlossen in {importResult.durationSeconds}s:
|
|
{' '}{importResult.accounts} Konten, {importResult.journalEntries} Buchungen ({importResult.journalLines} Zeilen),
|
|
{' '}{importResult.contacts} Kontakte, {importResult.accountBalances} Salden
|
|
</div>
|
|
)}
|
|
|
|
{importStatus && (importStatus.accounts > 0 || importStatus.journalEntries > 0) && (
|
|
<div style={{ marginTop: '0.75rem', fontSize: '0.85rem', color: 'var(--text-secondary)' }}>
|
|
<strong>Aktueller Datenbestand:</strong>{' '}
|
|
{importStatus.accounts} Konten, {importStatus.journalEntries} Buchungen,
|
|
{' '}{importStatus.journalLines} Zeilen, {importStatus.contacts} Kontakte,
|
|
{' '}{importStatus.accountBalances} Salden
|
|
{importStatus.lastSyncAt && (
|
|
<> · Letzter Import: {new Date(importStatus.lastSyncAt * 1000).toLocaleString()}</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<ConfirmDialog />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default TrusteeAccountingSettingsView;
|