ui-nyla/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx
2026-02-22 00:07:40 +01:00

299 lines
12 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 {
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 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 _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;
if (!window.confirm('Remove the accounting integration? This does not delete synced data.')) 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 !== '' && (
<> &middot; 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 !== '' && (
<> &middot; 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>
)}
</div>
</div>
);
};
export default TrusteeAccountingSettingsView;