From 59017138ff3286d6e00b92afc0d8392b7bb5090b Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 20 Apr 2026 17:51:07 +0200 Subject: [PATCH] feature fixes --- src/api/trusteeApi.ts | 1 + src/components/OnboardingAssistant.tsx | 6 +- src/hooks/useBackgroundJob.ts | 124 ++++++++++ .../trustee/TrusteeAccountingSettingsView.tsx | 219 +++++++++++++----- 4 files changed, 286 insertions(+), 64 deletions(-) create mode 100644 src/hooks/useBackgroundJob.ts 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 => ( -
- - 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 ( +
+ + 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».')} +
+ )} +
+ ); + })()} +
@@ -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}` : ''} +
+
+ )}