diff --git a/src/api/trusteeApi.ts b/src/api/trusteeApi.ts
index 6a3fae0..d700f8b 100644
--- a/src/api/trusteeApi.ts
+++ b/src/api/trusteeApi.ts
@@ -115,6 +115,7 @@ export interface AccountingConnectorInfo {
secret: boolean;
required: boolean;
placeholder?: string;
+ suggestions?: string[];
}>;
}
diff --git a/src/components/OnboardingAssistant.tsx b/src/components/OnboardingAssistant.tsx
index 717cc79..7e77a9f 100644
--- a/src/components/OnboardingAssistant.tsx
+++ b/src/components/OnboardingAssistant.tsx
@@ -40,7 +40,7 @@ function _hideOnboarding(): void {
}
const OnboardingAssistant: React.FC = ({ onDismiss }) => {
- const { t } = useLanguage();
+ const { t, currentLanguage } = useLanguage();
const callouts = useMemo(() => ({
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.'),
@@ -73,7 +73,7 @@ const OnboardingAssistant: React.FC = ({ onDismiss })
let workspaceInstancePath: string | undefined;
let workspaceInstanceIds: string[] = [];
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 dynamicBlock = blocks.find((b: { type: string }) => b.type === 'dynamic');
const mandates = dynamicBlock?.mandates || [];
@@ -165,7 +165,7 @@ const OnboardingAssistant: React.FC = ({ onDismiss })
} finally {
setLoading(false);
}
- }, [navigate, t]);
+ }, [navigate, t, currentLanguage]);
useEffect(() => {
const state = location.state as { showOnboarding?: number } | null;
diff --git a/src/hooks/useBackgroundJob.ts b/src/hooks/useBackgroundJob.ts
new file mode 100644
index 0000000..e21bdc2
--- /dev/null
+++ b/src/hooks/useBackgroundJob.ts
@@ -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;
+ result?: Record | 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;
+}
+
+/**
+ * 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(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 => {
+ 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 | 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(); } };
+}
diff --git a/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx b/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx
index b3d2b1b..8009fdd 100644
--- a/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx
+++ b/src/pages/views/trustee/TrusteeAccountingSettingsView.tsx
@@ -6,12 +6,13 @@
* 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 { 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,
@@ -43,16 +44,20 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
const [importDone, setImportDone] = useState(false);
const [importResult, setImportResult] = useState | null>(null);
const [importStatus, setImportStatus] = useState | null>(null);
+ const [importJobId, setImportJobId] = useState(null);
const [clearingCache, setClearingCache] = useState(false);
const [exporting, setExporting] = useState(false);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
- const mountedRef = useRef(true);
const { confirm, ConfirmDialog } = useConfirm();
useEffect(() => {
if (!importDone) return;
- const importResetTimer = setTimeout(() => { setImporting(false); setImportDone(false); }, 5000);
+ const importResetTimer = setTimeout(() => {
+ setImporting(false);
+ setImportDone(false);
+ setImportJobId(null);
+ }, 5000);
return () => clearTimeout(importResetTimer);
}, [importDone]);
@@ -82,21 +87,50 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
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 */ }
+ 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;
+ 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);
};
@@ -191,37 +225,26 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
{existingConfig?.configured && (
{t('Verbunden:')} {existingConfig.displayLabel || existingConfig.connectorType}
- {existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
- <> · {t('Letzter Sync:')} {existingConfig.lastSyncStatus}>
- )}
)}
- {existingConfig?.configured && (existingConfig.lastSyncAt != null || existingConfig.lastSyncStatus != null) && (
+ {existingConfig?.configured && existingConfig.lastSyncStatus === 'error' && (
0
-
{t('Syncstatus Fehlerprotokoll')}
+
{t('Letzter Sync fehlgeschlagen')}
{existingConfig.lastSyncAt != null && (
- {t('Letzter Sync:')} {' '}
+ {t('Zeitpunkt:')} {' '}
{new Date(existingConfig.lastSyncAt * 1000).toLocaleString()}
- {existingConfig.lastSyncStatus != null && existingConfig.lastSyncStatus !== '' && (
- <> · {t('Status:')} {existingConfig.lastSyncStatus}>
- )}
-
- )}
- {existingConfig.lastSyncStatus === 'error' && (existingConfig.lastSyncErrorMessage ?? '').trim() !== '' && (
-
- {existingConfig.lastSyncErrorMessage}
-
- )}
- {existingConfig.lastSyncStatus === 'error' && (!existingConfig.lastSyncErrorMessage || existingConfig.lastSyncErrorMessage.trim() === '') && (
-
- Der letzte Sync ist fehlgeschlagen. Details pro Position finden Sie unter Positionen (Spalte Sync-Status).
)}
+
+ {(existingConfig.lastSyncErrorMessage ?? '').trim() !== ''
+ ? existingConfig.lastSyncErrorMessage
+ : t('Der letzte Sync ist fehlgeschlagen. Details pro Position finden Sie unter Positionen (Spalte Sync-Status).')}
+
@@ -266,22 +289,35 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
placeholder={t('z. B. Run My Accounts – Muster AG')}
/>
- {selectedConnector.configFields.map(field => (
-
-
- {field.label || field.key}
- {field.required && * }
-
- handleConfigChange(field.key, e.target.value)}
- placeholder={field.placeholder || ''}
- autoComplete={field.secret ? 'new-password' : 'off'}
- />
-
- ))}
+ {selectedConnector.configFields.map(field => {
+ const datalistId = field.suggestions && field.suggestions.length > 0
+ ? `dl-${selectedConnector.connectorType}-${field.key}`
+ : undefined;
+ return (
+
+
+ {field.label || field.key}
+ {field.required && * }
+
+ handleConfigChange(field.key, e.target.value)}
+ placeholder={field.placeholder || ''}
+ autoComplete={field.secret ? 'new-password' : 'off'}
+ list={datalistId}
+ />
+ {datalistId && (
+
+ {field.suggestions!.map(s => (
+
+ ))}
+
+ )}
+
+ );
+ })}
@@ -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.')}
+ {(() => {
+ 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;
+ 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 (
+
+ {lastSyncAt ? (
+ <>
+
+ {t('Letzter Import:')} {new Date(lastSyncAt * 1000).toLocaleString()}
+ {timeWindow && (
+ <> {' '}· {t('Zeitfenster:')} {timeWindow}>
+ )}
+
+
+ {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),
+ })}
+
+ >
+ ) : (
+
+ {t('Noch kein Import durchgeführt. Wähle unten ein Zeitfenster und klicke auf «Daten jetzt einlesen».')}
+
+ )}
+
+ );
+ })()}
+
{t('Von (optional)')}
@@ -384,35 +469,47 @@ export const TrusteeAccountingSettingsView: React.FC = () => {
if (!instanceId) return;
setImporting(true);
setImportResult(null);
+ setImportJobId(null);
try {
const body: Record
= {};
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(t('Import teilweise fehlgeschlagen'), res.data.errors.join('; '));
- } else {
- showSuccess(t('Import abgeschlossen'),
- 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();
+ 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'));
- } finally {
setImportDone(true);
}
}}
>
- {importing ? t('Importiere…') : t('Daten jetzt einlesen')}
+ {importing
+ ? (importJob?.progressMessage || t('Importiere…'))
+ : t('Daten jetzt einlesen')}
+ {importing && importJob && (
+
+
+
+ {importJob.progress}% {importJob.progressMessage ? `· ${importJob.progressMessage}` : ''}
+
+
+ )}
{
if (!instanceId) return;
setClearingCache(true);
try {
- const res = 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) }));
+ 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 {