feature fixes
This commit is contained in:
parent
be748b162c
commit
59017138ff
4 changed files with 286 additions and 64 deletions
|
|
@ -115,6 +115,7 @@ export interface AccountingConnectorInfo {
|
||||||
secret: boolean;
|
secret: boolean;
|
||||||
required: boolean;
|
required: boolean;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
suggestions?: string[];
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ function _hideOnboarding(): void {
|
||||||
}
|
}
|
||||||
|
|
||||||
const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss }) => {
|
const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss }) => {
|
||||||
const { t } = useLanguage();
|
const { t, currentLanguage } = useLanguage();
|
||||||
const callouts = useMemo(() => ({
|
const callouts = useMemo(() => ({
|
||||||
mandate: t('Tipp: Ein Mandant ist Ihr Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.'),
|
mandate: t('Tipp: Ein Mandant ist Ihr Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.'),
|
||||||
feature: t('Tipp: Im Store finden Sie AI-Workspace, CommCoach und weitere Features. Aktivieren Sie mindestens eines, um loszulegen.'),
|
feature: t('Tipp: Im Store finden Sie AI-Workspace, CommCoach und weitere Features. Aktivieren Sie mindestens eines, um loszulegen.'),
|
||||||
|
|
@ -73,7 +73,7 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
|
||||||
let workspaceInstancePath: string | undefined;
|
let workspaceInstancePath: string | undefined;
|
||||||
let workspaceInstanceIds: string[] = [];
|
let workspaceInstanceIds: string[] = [];
|
||||||
try {
|
try {
|
||||||
const navRes = await api.get('/api/navigation?language=de');
|
const navRes = await api.get(`/api/navigation?language=${currentLanguage}`);
|
||||||
const blocks = navRes.data?.blocks || [];
|
const blocks = navRes.data?.blocks || [];
|
||||||
const dynamicBlock = blocks.find((b: { type: string }) => b.type === 'dynamic');
|
const dynamicBlock = blocks.find((b: { type: string }) => b.type === 'dynamic');
|
||||||
const mandates = dynamicBlock?.mandates || [];
|
const mandates = dynamicBlock?.mandates || [];
|
||||||
|
|
@ -165,7 +165,7 @@ const OnboardingAssistant: React.FC<OnboardingAssistantProps> = ({ onDismiss })
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [navigate, t]);
|
}, [navigate, t, currentLanguage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state = location.state as { showOnboarding?: number } | null;
|
const state = location.state as { showOnboarding?: number } | null;
|
||||||
|
|
|
||||||
124
src/hooks/useBackgroundJob.ts
Normal file
124
src/hooks/useBackgroundJob.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import api from '../api';
|
||||||
|
|
||||||
|
export type BackgroundJobStatus = 'PENDING' | 'RUNNING' | 'SUCCESS' | 'ERROR' | 'CANCELLED';
|
||||||
|
|
||||||
|
export interface BackgroundJob {
|
||||||
|
id: string;
|
||||||
|
jobType: string;
|
||||||
|
mandateId?: string | null;
|
||||||
|
featureInstanceId?: string | null;
|
||||||
|
triggeredBy?: string | null;
|
||||||
|
status: BackgroundJobStatus;
|
||||||
|
progress: number;
|
||||||
|
progressMessage?: string | null;
|
||||||
|
payload?: Record<string, any>;
|
||||||
|
result?: Record<string, any> | null;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
createdAt?: string;
|
||||||
|
startedAt?: string | null;
|
||||||
|
finishedAt?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMINAL_STATUSES: BackgroundJobStatus[] = ['SUCCESS', 'ERROR', 'CANCELLED'];
|
||||||
|
|
||||||
|
export interface UseBackgroundJobOptions {
|
||||||
|
pollMs?: number;
|
||||||
|
enabled?: boolean;
|
||||||
|
onSuccess?: (job: BackgroundJob) => void;
|
||||||
|
onError?: (job: BackgroundJob) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseBackgroundJobResult {
|
||||||
|
job: BackgroundJob | null;
|
||||||
|
isFinal: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
isLoading: boolean;
|
||||||
|
refetch: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls /api/jobs/{jobId} until the job reaches a terminal status.
|
||||||
|
*
|
||||||
|
* Use after submitting a long-running task to the generic background job
|
||||||
|
* service. Handles polling, cleanup on unmount, and exposes the job record
|
||||||
|
* directly so callers can read `job.progress`, `job.result`, etc.
|
||||||
|
*/
|
||||||
|
export function useBackgroundJob(
|
||||||
|
jobId: string | null | undefined,
|
||||||
|
opts: UseBackgroundJobOptions = {},
|
||||||
|
): UseBackgroundJobResult {
|
||||||
|
const { pollMs = 2000, enabled = true, onSuccess, onError } = opts;
|
||||||
|
const [job, setJob] = useState<BackgroundJob | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const mountedRef = useRef(true);
|
||||||
|
const onSuccessRef = useRef(onSuccess);
|
||||||
|
const onErrorRef = useRef(onError);
|
||||||
|
|
||||||
|
useEffect(() => { onSuccessRef.current = onSuccess; }, [onSuccess]);
|
||||||
|
useEffect(() => { onErrorRef.current = onError; }, [onError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mountedRef.current = true;
|
||||||
|
return () => { mountedRef.current = false; };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchOnce = useCallback(async (): Promise<BackgroundJob | null> => {
|
||||||
|
if (!jobId) return null;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/api/jobs/${jobId}`);
|
||||||
|
const next = res.data as BackgroundJob;
|
||||||
|
if (mountedRef.current) setJob(next);
|
||||||
|
return next;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (mountedRef.current) {
|
||||||
|
setJob(prev => prev ?? {
|
||||||
|
id: jobId,
|
||||||
|
jobType: '',
|
||||||
|
status: 'ERROR',
|
||||||
|
progress: 0,
|
||||||
|
errorMessage: err?.response?.data?.detail || err?.message || 'Job nicht abrufbar',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
if (mountedRef.current) setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [jobId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !jobId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let firedTerminal = false;
|
||||||
|
|
||||||
|
const tick = async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const next = await fetchOnce();
|
||||||
|
if (cancelled) return;
|
||||||
|
const status = next?.status;
|
||||||
|
if (status && TERMINAL_STATUSES.includes(status)) {
|
||||||
|
if (!firedTerminal) {
|
||||||
|
firedTerminal = true;
|
||||||
|
if (status === 'SUCCESS') onSuccessRef.current?.(next!);
|
||||||
|
else onErrorRef.current?.(next!);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = setTimeout(tick, pollMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
tick();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [jobId, enabled, pollMs, fetchOnce]);
|
||||||
|
|
||||||
|
const isFinal = !!job && TERMINAL_STATUSES.includes(job.status);
|
||||||
|
const isError = job?.status === 'ERROR' || job?.status === 'CANCELLED';
|
||||||
|
|
||||||
|
return { job, isFinal, isError, isLoading, refetch: async () => { await fetchOnce(); } };
|
||||||
|
}
|
||||||
|
|
@ -6,12 +6,13 @@
|
||||||
* testing the connection, and removing the integration.
|
* testing the connection, and removing the integration.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||||
import { useApiRequest } from '../../../hooks/useApi';
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import { useConfirm } from '../../../hooks/useConfirm';
|
import { useConfirm } from '../../../hooks/useConfirm';
|
||||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { useBackgroundJob } from '../../../hooks/useBackgroundJob';
|
||||||
import {
|
import {
|
||||||
fetchAccountingConnectors,
|
fetchAccountingConnectors,
|
||||||
fetchAccountingConfig,
|
fetchAccountingConfig,
|
||||||
|
|
@ -43,16 +44,20 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
const [importDone, setImportDone] = useState(false);
|
const [importDone, setImportDone] = useState(false);
|
||||||
const [importResult, setImportResult] = useState<Record<string, any> | null>(null);
|
const [importResult, setImportResult] = useState<Record<string, any> | null>(null);
|
||||||
const [importStatus, setImportStatus] = 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 [clearingCache, setClearingCache] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [dateFrom, setDateFrom] = useState('');
|
const [dateFrom, setDateFrom] = useState('');
|
||||||
const [dateTo, setDateTo] = useState('');
|
const [dateTo, setDateTo] = useState('');
|
||||||
const mountedRef = useRef(true);
|
|
||||||
const { confirm, ConfirmDialog } = useConfirm();
|
const { confirm, ConfirmDialog } = useConfirm();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!importDone) return;
|
if (!importDone) return;
|
||||||
const importResetTimer = setTimeout(() => { setImporting(false); setImportDone(false); }, 5000);
|
const importResetTimer = setTimeout(() => {
|
||||||
|
setImporting(false);
|
||||||
|
setImportDone(false);
|
||||||
|
setImportJobId(null);
|
||||||
|
}, 5000);
|
||||||
return () => clearTimeout(importResetTimer);
|
return () => clearTimeout(importResetTimer);
|
||||||
}, [importDone]);
|
}, [importDone]);
|
||||||
|
|
||||||
|
|
@ -82,21 +87,50 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
return () => { mountedRef.current = false; };
|
|
||||||
}, [loadData]);
|
}, [loadData]);
|
||||||
|
|
||||||
const _loadImportStatus = useCallback(async () => {
|
const _loadImportStatus = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
try {
|
try {
|
||||||
const res = await request({ url: `/api/trustee/${instanceId}/accounting/import-status`, method: 'get' });
|
const data = await request({ url: `/api/trustee/${instanceId}/accounting/import-status`, method: 'get' });
|
||||||
if (mountedRef.current) setImportStatus(res.data);
|
setImportStatus(data);
|
||||||
} catch { /* ignore */ }
|
} catch (err) {
|
||||||
|
console.error('[Trustee] import-status fetch failed:', err);
|
||||||
|
}
|
||||||
}, [instanceId, request]);
|
}, [instanceId, request]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (existingConfig?.configured) _loadImportStatus();
|
if (existingConfig?.configured) _loadImportStatus();
|
||||||
}, [existingConfig, _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 => {
|
const _getSelectedConnector = (): AccountingConnectorInfo | undefined => {
|
||||||
return connectors.find(c => c.connectorType === selectedType);
|
return connectors.find(c => c.connectorType === selectedType);
|
||||||
};
|
};
|
||||||
|
|
@ -191,37 +225,26 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
{existingConfig?.configured && (
|
{existingConfig?.configured && (
|
||||||
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
|
<div className={styles.successMessage} style={{ marginBottom: '0.5rem' }}>
|
||||||
<strong>{t('Verbunden:')}</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
<strong>{t('Verbunden:')}</strong> {existingConfig.displayLabel || existingConfig.connectorType}
|
||||||
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
|
||||||
<> · {t('Letzter Sync:')} {existingConfig.lastSyncStatus}</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{existingConfig?.configured && (existingConfig.lastSyncAt != null || existingConfig.lastSyncStatus != null) && (
|
{existingConfig?.configured && existingConfig.lastSyncStatus === 'error' && (
|
||||||
<div className={styles.setupStep} style={{ marginTop: 0, marginBottom: '1rem' }}>
|
<div className={styles.setupStep} style={{ marginTop: 0, marginBottom: '1rem' }}>
|
||||||
<div className={styles.stepNumber} style={{ visibility: 'hidden' }}>0</div>
|
<div className={styles.stepNumber} style={{ visibility: 'hidden' }}>0</div>
|
||||||
<div className={styles.stepContent}>
|
<div className={styles.stepContent}>
|
||||||
<h4 style={{ marginTop: 0 }}>{t('Syncstatus Fehlerprotokoll')}</h4>
|
<h4 style={{ marginTop: 0 }}>{t('Letzter Sync fehlgeschlagen')}</h4>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
|
||||||
{existingConfig.lastSyncAt != null && (
|
{existingConfig.lastSyncAt != null && (
|
||||||
<div style={{ fontSize: '0.9rem' }}>
|
<div style={{ fontSize: '0.9rem' }}>
|
||||||
<strong>{t('Letzter Sync:')}</strong>{' '}
|
<strong>{t('Zeitpunkt:')}</strong>{' '}
|
||||||
{new Date(existingConfig.lastSyncAt * 1000).toLocaleString()}
|
{new Date(existingConfig.lastSyncAt * 1000).toLocaleString()}
|
||||||
{existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
|
|
||||||
<> · {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 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -266,22 +289,35 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
placeholder={t('z. B. Run My Accounts – Muster AG')}
|
placeholder={t('z. B. Run My Accounts – Muster AG')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{selectedConnector.configFields.map(field => (
|
{selectedConnector.configFields.map(field => {
|
||||||
<div key={field.key}>
|
const datalistId = field.suggestions && field.suggestions.length > 0
|
||||||
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.85rem' }}>
|
? `dl-${selectedConnector.connectorType}-${field.key}`
|
||||||
{field.label || field.key}
|
: undefined;
|
||||||
{field.required && <span style={{ color: 'var(--error-color, #dc2626)' }}> *</span>}
|
return (
|
||||||
</label>
|
<div key={field.key}>
|
||||||
<input
|
<label style={{ display: 'block', marginBottom: '0.25rem', fontSize: '0.85rem' }}>
|
||||||
type={field.secret ? 'password' : 'text'}
|
{field.label || field.key}
|
||||||
className={styles.folderSelect}
|
{field.required && <span style={{ color: 'var(--error-color, #dc2626)' }}> *</span>}
|
||||||
value={configValues[field.key] || ''}
|
</label>
|
||||||
onChange={e => handleConfigChange(field.key, e.target.value)}
|
<input
|
||||||
placeholder={field.placeholder || ''}
|
type={field.secret ? 'password' : 'text'}
|
||||||
autoComplete={field.secret ? 'new-password' : 'off'}
|
className={styles.folderSelect}
|
||||||
/>
|
value={configValues[field.key] || ''}
|
||||||
</div>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -340,6 +376,55 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
{t('Kontenplan, Buchungen, Kontakte und Salden aus dem Buchhaltungssystem einlesen. Diese Daten stehen anschließend im KI-Workspace für Analysen zur Verfügung.')}
|
{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>
|
</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 style={{ display: 'flex', gap: '0.75rem', marginBottom: '0.5rem', flexWrap: 'wrap', alignItems: 'flex-end' }}>
|
||||||
<div>
|
<div>
|
||||||
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>{t('Von (optional)')}</label>
|
<label style={{ display: 'block', fontSize: '0.8rem', marginBottom: '0.2rem' }}>{t('Von (optional)')}</label>
|
||||||
|
|
@ -384,35 +469,47 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
setImportResult(null);
|
setImportResult(null);
|
||||||
|
setImportJobId(null);
|
||||||
try {
|
try {
|
||||||
const body: Record<string, string> = {};
|
const body: Record<string, string> = {};
|
||||||
if (dateFrom) body.dateFrom = dateFrom;
|
if (dateFrom) body.dateFrom = dateFrom;
|
||||||
if (dateTo) body.dateTo = dateTo;
|
if (dateTo) body.dateTo = dateTo;
|
||||||
const res = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
|
const result = await request({ url: `/api/trustee/${instanceId}/accounting/import-data`, method: 'post', data: body });
|
||||||
if (mountedRef.current) {
|
const newJobId: string | undefined = result?.jobId;
|
||||||
setImportResult(res.data);
|
if (newJobId) {
|
||||||
if (res.data.errors?.length) {
|
setImportJobId(newJobId);
|
||||||
showError(t('Import teilweise fehlgeschlagen'), res.data.errors.join('; '));
|
} else {
|
||||||
} else {
|
showError(t('Import fehlgeschlagen'), t('Kein jobId vom Server erhalten'));
|
||||||
showSuccess(t('Import abgeschlossen'),
|
setImportDone(true);
|
||||||
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) {
|
} catch (err: any) {
|
||||||
showError(t('Import fehlgeschlagen'), err.response?.data?.detail || err.message || t('Unbekannter Fehler'));
|
showError(t('Import fehlgeschlagen'), err.response?.data?.detail || err.message || t('Unbekannter Fehler'));
|
||||||
} finally {
|
|
||||||
setImportDone(true);
|
setImportDone(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{importing ? t('Importiere…') : t('Daten jetzt einlesen')}
|
{importing
|
||||||
|
? (importJob?.progressMessage || t('Importiere…'))
|
||||||
|
: t('Daten jetzt einlesen')}
|
||||||
</button>
|
</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
|
<button
|
||||||
className={styles.secondaryButton}
|
className={styles.secondaryButton}
|
||||||
disabled={clearingCache}
|
disabled={clearingCache}
|
||||||
|
|
@ -420,8 +517,8 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setClearingCache(true);
|
setClearingCache(true);
|
||||||
try {
|
try {
|
||||||
const res = await request({ url: `/api/trustee/${instanceId}/accounting/clear-cache`, method: 'post' });
|
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(res.data?.cleared ?? 0) }));
|
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) {
|
} catch (err: any) {
|
||||||
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Cache konnte nicht geleert werden.'));
|
showError(t('Fehler'), err.response?.data?.detail || err.message || t('Cache konnte nicht geleert werden.'));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue