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/api/workflowApi.ts b/src/api/workflowApi.ts
index a321c40..8c7a9e2 100644
--- a/src/api/workflowApi.ts
+++ b/src/api/workflowApi.ts
@@ -392,6 +392,108 @@ export async function deleteWorkflow(
});
}
+// -------------------------------------------------------------------------
+// Workflow file IO (envelopeVersioned, .workflow.json)
+// -------------------------------------------------------------------------
+
+/** envelopeVersioned schema 1.0 — keys mirror the gateway constants. */
+export const WORKFLOW_FILE_SCHEMA_VERSION = '1.0';
+export const WORKFLOW_FILE_KIND = 'poweron.workflow';
+export const WORKFLOW_FILE_EXTENSION = '.workflow.json';
+
+export interface WorkflowFileEnvelope {
+ $schemaVersion: string;
+ $kind: string;
+ $exportedAt?: string;
+ $gatewayVersion?: string;
+ label: string;
+ description?: string;
+ tags?: string[];
+ templateScope?: AutoTemplateScope;
+ sharedReadOnly?: boolean;
+ notifyOnFailure?: boolean;
+ graph: Automation2Graph;
+ invocations?: WorkflowEntryPoint[];
+}
+
+export interface ImportWorkflowResponse {
+ workflow: AutoWorkflow;
+ warnings: string[];
+ created: boolean;
+}
+
+export interface ImportWorkflowOptions {
+ /** Inline envelope payload (preferred for round-trip in the editor). */
+ envelope?: WorkflowFileEnvelope;
+ /** UDB FileItem.id of a previously uploaded ``.workflow.json``. */
+ fileId?: string;
+ /** When set, the existing workflow is replaced instead of a new one being created. */
+ existingWorkflowId?: string;
+}
+
+/** POST /api/workflows/{instanceId}/workflows/import */
+export async function importWorkflowFromFile(
+ request: ApiRequestFunction,
+ instanceId: string,
+ options: ImportWorkflowOptions,
+): Promise {
+ if (!options.envelope && !options.fileId) {
+ throw new Error('importWorkflowFromFile: either envelope or fileId is required');
+ }
+ return await request({
+ url: `/api/workflows/${instanceId}/workflows/import`,
+ method: 'post',
+ data: options,
+ });
+}
+
+export interface ExportWorkflowResult {
+ fileName: string;
+ envelope: WorkflowFileEnvelope;
+}
+
+/**
+ * GET /api/workflows/{instanceId}/workflows/{workflowId}/export
+ *
+ * The backend returns ``{ fileName, envelope }`` when ``download=false`` and a
+ * raw JSON download (``Content-Disposition: attachment``) when ``download=true``.
+ * For programmatic use (e.g. re-uploading to UDB) keep download=false.
+ */
+export async function exportWorkflowToFile(
+ request: ApiRequestFunction,
+ instanceId: string,
+ workflowId: string,
+ download = false,
+): Promise {
+ return await request({
+ url: `/api/workflows/${instanceId}/workflows/${workflowId}/export`,
+ method: 'get',
+ params: { download },
+ });
+}
+
+/** Quick content-sniffing — used by the UDB FilesTab to flag workflow files. */
+export function isWorkflowFileContent(payload: unknown): boolean {
+ if (!payload || typeof payload !== 'object') return false;
+ const p = payload as Record;
+ return (
+ typeof p.$schemaVersion === 'string' &&
+ p.$kind === WORKFLOW_FILE_KIND &&
+ !!p.graph &&
+ typeof p.graph === 'object'
+ );
+}
+
+/** Suggest a safe filename from the workflow label (mirrors gateway buildFileName). */
+export function workflowFileNameFor(label: string): string {
+ const slug = (label || 'workflow')
+ .toLowerCase()
+ .replace(/[^a-z0-9._-]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 80) || 'workflow';
+ return `${slug}${WORKFLOW_FILE_EXTENSION}`;
+}
+
/** Delete by workflow ID only (Automations dashboard / orphan rows without featureInstanceId). */
export async function deleteSystemWorkflow(
request: ApiRequestFunction,
diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx
index 1438279..5a0d0d1 100644
--- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx
+++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx
@@ -275,14 +275,35 @@ export const Automation2FlowEditor: React.FC = ({ in
} else {
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
}
+ setWorkflows((prev) => {
+ const idx = prev.findIndex((w) => w.id === workflowId);
+ if (idx === -1) return [...prev, wf];
+ const next = prev.slice();
+ next[idx] = { ...prev[idx], ...wf };
+ return next;
+ });
} catch (err: unknown) {
+ const status = (err as { response?: { status?: number } })?.response?.status;
+ if (status === 404) {
+ setWorkflows((prev) => prev.filter((w) => w.id !== workflowId));
+ setCurrentWorkflowId((prev) => (prev === workflowId ? null : prev));
+ setExecuteResult(null);
+ applyGraphWithSync({ nodes: [], connections: [] }, _buildDefaultInvocations(t('Jetzt ausführen')));
+ try {
+ const result = await fetchWorkflows(request, instanceId);
+ setWorkflows(Array.isArray(result) ? result : result.items);
+ } catch (refreshErr) {
+ console.error(`${LOG} workflows refresh failed`, refreshErr);
+ }
+ return;
+ }
setExecuteResult({
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
},
- [request, instanceId, handleFromApiGraph, applyGraphWithSync]
+ [request, instanceId, handleFromApiGraph, applyGraphWithSync, t]
);
const handleWorkflowSelect = useCallback(
diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
index f966f32..3d86c7f 100644
--- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
+++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx
@@ -153,15 +153,23 @@ const HiddenInput: React.FC = () => null;
const ConnectionPicker: React.FC = ({ param, value, onChange, instanceId, request }) => {
const { t } = useLanguage();
const [connections, setConnections] = React.useState>([]);
+ const [loadError, setLoadError] = React.useState(null);
+ const authority = (param.frontendOptions?.authority as string | undefined) || undefined;
React.useEffect(() => {
if (!instanceId || !request) return;
- request({ url: `/api/graphicalEditor/${instanceId}/options/user.connection`, method: 'get' })
+ const qs = authority ? `?authority=${encodeURIComponent(authority)}` : '';
+ setLoadError(null);
+ request({ url: `/api/workflows/${instanceId}/options/user.connection${qs}`, method: 'get' })
.then((res: unknown) => {
const data = res as { options?: Array<{ value: string; label: string }> };
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
})
- .catch(() => {});
- }, [instanceId, request]);
+ .catch((err: unknown) => {
+ console.error('ConnectionPicker: failed to load connections', err);
+ setConnections([]);
+ setLoadError(err instanceof Error ? err.message : String(err));
+ });
+ }, [instanceId, request, authority]);
return (
{param.description || param.name}
@@ -175,6 +183,16 @@ const ConnectionPicker: React.FC
= ({ param, value, onChange
{c.label}
))}
+ {!loadError && connections.length === 0 && (
+
+ {authority
+ ? t('Keine {authority}-Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.', { authority })
+ : t('Keine Verbindung gefunden — Verbindung in den Account-Einstellungen anlegen.')}
+
+ )}
+ {loadError && (
+ {t('Verbindungen konnten nicht geladen werden')}
+ )}
);
};
@@ -199,6 +217,205 @@ const FolderPicker: React.FC = ({ param, value, onChange, al
);
};
+type SharepointSiteOption = { type: 'site'; value: string; label: string; siteId: string; path: string; webUrl?: string };
+type SharepointItemOption = { type: 'folder' | 'file'; value: string; label: string; path: string; siteId: string };
+
+const SharepointPathPicker: React.FC = ({ param, value, onChange, allParams, request }) => {
+ const { t } = useLanguage();
+ const isFilePicker = param.frontendType === 'sharepointFile';
+ const dependsOn = (param.frontendOptions?.dependsOn as string | undefined) || 'connectionReference';
+ const connectionReference = (allParams?.[dependsOn] as string | undefined) || '';
+ const hasConnection = !!connectionReference;
+
+ const [open, setOpen] = React.useState(false);
+ const [sites, setSites] = React.useState([]);
+ const [selectedSite, setSelectedSite] = React.useState(null);
+ const [currentPath, setCurrentPath] = React.useState('');
+ const [items, setItems] = React.useState([]);
+ const [loading, setLoading] = React.useState(false);
+ const [error, setError] = React.useState(null);
+
+ const loadSites = React.useCallback(async () => {
+ if (!request || !connectionReference) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const res = (await request({
+ url: `/api/sharepoint/folder-options?connectionReference=${encodeURIComponent(connectionReference)}`,
+ method: 'get',
+ })) as SharepointSiteOption[] | null;
+ setSites(Array.isArray(res) ? res : []);
+ } catch (err: unknown) {
+ console.error('SharepointPathPicker: failed to load sites', err);
+ setSites([]);
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ }
+ }, [request, connectionReference]);
+
+ const loadItems = React.useCallback(async (site: SharepointSiteOption, path: string) => {
+ if (!request) return;
+ setLoading(true);
+ setError(null);
+ try {
+ const params = new URLSearchParams({ connectionReference, siteId: site.siteId });
+ if (path) params.append('path', path);
+ if (isFilePicker) params.append('includeFiles', 'true');
+ const res = (await request({
+ url: `/api/sharepoint/folder-options?${params.toString()}`,
+ method: 'get',
+ })) as SharepointItemOption[] | null;
+ setItems(Array.isArray(res) ? res : []);
+ } catch (err: unknown) {
+ console.error('SharepointPathPicker: failed to load items', err);
+ setItems([]);
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ }
+ }, [request, connectionReference, isFilePicker]);
+
+ React.useEffect(() => {
+ if (!open) return;
+ if (sites.length === 0 && !loading) loadSites();
+ }, [open, sites.length, loading, loadSites]);
+
+ React.useEffect(() => {
+ if (selectedSite) {
+ setCurrentPath('');
+ loadItems(selectedSite, '');
+ } else {
+ setItems([]);
+ }
+ }, [selectedSite, loadItems]);
+
+ const navigateInto = (item: SharepointItemOption) => {
+ if (item.type !== 'folder' || !selectedSite) return;
+ setCurrentPath(item.path);
+ loadItems(selectedSite, item.path);
+ };
+
+ const goUp = () => {
+ if (!selectedSite || !currentPath) return;
+ const parts = currentPath.split('/');
+ parts.pop();
+ const parent = parts.join('/');
+ setCurrentPath(parent);
+ loadItems(selectedSite, parent);
+ };
+
+ const buildFullPath = (sub: string) => {
+ const sitePath = (selectedSite?.path || '').replace(/\/+$/, '');
+ const cleanSub = sub.replace(/^\/+/, '');
+ if (!sitePath) return `/${cleanSub}`;
+ if (!cleanSub) return sitePath;
+ return `${sitePath}/${cleanSub}`;
+ };
+
+ const pickCurrentFolder = () => {
+ if (!selectedSite) return;
+ const fullPath = buildFullPath(currentPath);
+ onChange(fullPath);
+ setOpen(false);
+ };
+
+ const pickItem = (item: SharepointItemOption) => {
+ if (!selectedSite) return;
+ const fullPath = buildFullPath(item.path);
+ onChange(fullPath);
+ if (item.type === 'file') setOpen(false);
+ };
+
+ return (
+
+
{param.description || param.name}
+
+ onChange(e.target.value)}
+ disabled={!hasConnection}
+ placeholder={hasConnection ? (isFilePicker ? t('SharePoint-Dateipfad') : t('SharePoint-Ordnerpfad')) : t('Zuerst {field} wählen', { field: dependsOn })}
+ style={{ flex: 1, padding: '4px 8px', borderRadius: 4, border: '1px solid #ccc', opacity: hasConnection ? 1 : 0.5 }}
+ />
+ setOpen((o) => !o)}
+ title={t('In SharePoint browsen')}
+ style={{ padding: '4px 8px', borderRadius: 4, border: '1px solid #0078D4', background: open ? '#0078D4' : '#fff', color: open ? '#fff' : '#0078D4', cursor: hasConnection ? 'pointer' : 'not-allowed', opacity: hasConnection ? 1 : 0.5 }}
+ >
+ {open ? t('Schliessen') : t('Browsen')}
+
+
+
+ {open && hasConnection && (
+
+ {error &&
{error}
}
+
+ {t('Seite:')}
+ {
+ const s = sites.find((x) => x.siteId === e.target.value) || null;
+ setSelectedSite(s);
+ }}
+ style={{ flex: 1, padding: '2px 4px', borderRadius: 4, border: '1px solid #ccc', fontSize: 12 }}
+ >
+ {loading && sites.length === 0 ? t('Lade Seiten') : t('Seite wählen')}
+ {sites.map((s) => (
+ {s.label}
+ ))}
+
+ ↻
+
+
+ {selectedSite && (
+ <>
+
+ {t('Pfad:')} {selectedSite.path}/{currentPath || {t('(Stammverzeichnis)')} }
+
+
+ {currentPath && (
+ ↑ {t('Hoch')}
+ )}
+
+ ✓ {isFilePicker ? t('Diesen Ordner als Pfad nehmen') : t('Diesen Ordner wählen')}
+
+
+
+ {loading &&
{t('Lade')}
}
+ {!loading && items.length === 0 && (
+
{isFilePicker ? t('Keine Dateien oder Ordner') : t('Keine Unterordner')}
+ )}
+ {!loading && items.map((item) => (
+
+ (item.type === 'folder' ? navigateInto(item) : pickItem(item))}
+ style={{ flex: 1, cursor: 'pointer', userSelect: 'none' }}
+ title={item.type === 'folder' ? t('Öffnen') : t('Wählen')}
+ >
+ {item.type === 'folder' ? '📁' : '📄'} {item.label}
+
+ pickItem(item)}
+ style={{ padding: '1px 6px', borderRadius: 3, border: '1px solid #0078D4', background: '#fff', color: '#0078D4', cursor: 'pointer', fontSize: 11 }}
+ >
+ {t('Wählen')}
+
+
+ ))}
+
+ >
+ )}
+
+ )}
+
+ );
+};
+
const CaseListEditor: React.FC = ({ param, value, onChange }) => {
const { t } = useLanguage();
const cases = Array.isArray(value) ? value : [];
@@ -402,8 +619,8 @@ export const FRONTEND_TYPE_RENDERERS: Record = {
file: TextInput,
hidden: HiddenInput,
userConnection: ConnectionPicker,
- sharepointFolder: FolderPicker,
- sharepointFile: FolderPicker,
+ sharepointFolder: SharepointPathPicker,
+ sharepointFile: SharepointPathPicker,
clickupList: FolderPicker,
clickupTask: FolderPicker,
caseList: CaseListEditor,
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/graphicalEditor/GraphicalEditorWorkflowsPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx
index 3a0d11c..4d0c0ad 100644
--- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx
+++ b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsPage.tsx
@@ -6,9 +6,9 @@
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
*/
-import React, { useState, useCallback, useEffect } from 'react';
+import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
-import { FaPlay, FaSync, FaCheck, FaBan, FaPen } from 'react-icons/fa';
+import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa';
import { usePrompt } from '../../../hooks/usePrompt';
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
@@ -18,7 +18,13 @@ import {
deleteWorkflow,
executeGraph,
updateWorkflow,
+ importWorkflowFromFile,
+ exportWorkflowToFile,
+ isWorkflowFileContent,
+ workflowFileNameFor,
+ WORKFLOW_FILE_EXTENSION,
type Automation2Workflow,
+ type WorkflowFileEnvelope,
} from '../../../api/workflowApi';
import { useToast } from '../../../contexts/ToastContext';
import { formatUnixTimestamp } from '../../../utils/time';
@@ -56,6 +62,8 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [paginationMeta, setPaginationMeta] = useState(null);
+ const [importing, setImporting] = useState(false);
+ const importFileInputRef = useRef(null);
const load = useCallback(async (paginationParams?: any) => {
if (!instanceId) return;
@@ -180,6 +188,69 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
[instanceId, request, showSuccess, showError, load, t]
);
+ const handleExport = useCallback(
+ async (row: Automation2Workflow) => {
+ if (!instanceId) return;
+ try {
+ const result = await exportWorkflowToFile(request, instanceId, row.id, false);
+ const fileName = result.fileName || workflowFileNameFor(row.label);
+ const blob = new Blob([JSON.stringify(result.envelope, null, 2)], {
+ type: 'application/json;charset=utf-8',
+ });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = fileName;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ showSuccess(t('Workflow als Datei exportiert'));
+ } catch (e: any) {
+ showError(t('Fehler: {msg}', { msg: e?.message || t('Export fehlgeschlagen') }));
+ }
+ },
+ [instanceId, request, showSuccess, showError, t],
+ );
+
+ const handleImportFileSelected = useCallback(
+ async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ e.target.value = '';
+ if (!file || !instanceId) return;
+ setImporting(true);
+ try {
+ const text = await file.text();
+ let envelope: WorkflowFileEnvelope;
+ try {
+ envelope = JSON.parse(text) as WorkflowFileEnvelope;
+ } catch {
+ showError(t('Datei ist kein gültiges JSON'));
+ return;
+ }
+ if (!isWorkflowFileContent(envelope)) {
+ showError(t('Datei ist kein PowerOn-Workflow ({ext})', { ext: WORKFLOW_FILE_EXTENSION }));
+ return;
+ }
+ const result = await importWorkflowFromFile(request, instanceId, { envelope });
+ const warnings = result?.warnings ?? [];
+ if (warnings.length > 0) {
+ showSuccess(
+ t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', { n: warnings.length }),
+ );
+ } else {
+ showSuccess(t('Workflow importiert (deaktiviert). Bitte vor Aktivierung prüfen.'));
+ }
+ await load();
+ } catch (e: any) {
+ showError(t('Fehler: {msg}', { msg: e?.message || t('Import fehlgeschlagen') }));
+ } finally {
+ setImporting(false);
+ }
+ },
+ [instanceId, request, showSuccess, showError, load, t],
+ );
+
const columns: ColumnConfig[] = [
{ key: 'label', label: t('Workflow'), type: 'string', width: 200, sortable: true },
{
@@ -274,6 +345,21 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
))}
+ importFileInputRef.current?.click()}
+ disabled={importing || loading}
+ title={t('Workflow aus Datei importieren ({ext})', { ext: WORKFLOW_FILE_EXTENSION })}
+ >
+ {importing ? t('Importiere...') : t('Importieren')}
+
+
load()}
@@ -337,6 +423,12 @@ export const GraphicalEditorWorkflowsPage: React.FC = () => {
loading: (row) => executingId === row.id,
visible: (row) => hasManualTrigger(row),
},
+ {
+ id: 'export',
+ icon: ,
+ title: t('Als Datei exportieren'),
+ onClick: (row) => handleExport(row),
+ },
]}
onDelete={(row) => handleDelete(row.id)}
hookData={hookData}
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 {
diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx
index 9c4ee92..de47569 100644
--- a/src/pages/views/workspace/WorkspaceInput.tsx
+++ b/src/pages/views/workspace/WorkspaceInput.tsx
@@ -45,6 +45,8 @@ interface WorkspaceInputProps {
onDataSourceDrop?: (params: { connectionId: string; sourceType: string; path: string; label: string; displayPath?: string }) => void;
pendingAttachDsId?: string;
onPendingAttachDsConsumed?: () => void;
+ pendingAttachFdsId?: string;
+ onPendingAttachFdsConsumed?: () => void;
onPasteAsFile?: (file: File) => void;
draftAppend?: string;
onDraftAppendConsumed?: () => void;
@@ -69,6 +71,8 @@ export const WorkspaceInput: React.FC = ({ instanceId: _ins
onDataSourceDrop,
pendingAttachDsId,
onPendingAttachDsConsumed,
+ pendingAttachFdsId,
+ onPendingAttachFdsConsumed,
onPasteAsFile,
draftAppend,
onDraftAppendConsumed,
@@ -105,6 +109,15 @@ export const WorkspaceInput: React.FC = ({ instanceId: _ins
}
}, [pendingAttachDsId, onPendingAttachDsConsumed]);
+ useEffect(() => {
+ if (pendingAttachFdsId) {
+ setAttachedFeatureDataSourceIds(prev =>
+ prev.includes(pendingAttachFdsId) ? prev : [...prev, pendingAttachFdsId],
+ );
+ onPendingAttachFdsConsumed?.();
+ }
+ }, [pendingAttachFdsId, onPendingAttachFdsConsumed]);
+
const promptBeforeVoiceRef = useRef('');
const finalizedTextRef = useRef('');
const currentInterimRef = useRef('');
diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx
index a35799c..d973a82 100644
--- a/src/pages/views/workspace/WorkspacePage.tsx
+++ b/src/pages/views/workspace/WorkspacePage.tsx
@@ -293,16 +293,29 @@ export const WorkspacePage: React.FC = ({ persistentInstance
});
}, []);
+ const [pendingAttachFdsId, setPendingAttachFdsId] = useState('');
+
const _handleSendToChat_FeatureSource = useCallback(async (params: AddToChat_FeatureSource) => {
try {
- await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
+ const res = await api.post(`/api/workspace/${instanceId}/feature-datasources`, {
featureInstanceId: params.featureInstanceId,
featureCode: params.featureCode,
tableName: params.tableName || '',
objectKey: params.objectKey,
label: params.label,
});
+ // Backend response shape parity with /datasources — accept either a flat
+ // ``id`` or a wrapped ``featureDataSource.id`` so a future API tweak
+ // doesn't silently break the chip again.
+ const newId =
+ res.data?.id ||
+ res.data?.featureDataSource?.id ||
+ res.data?.dataSource?.id ||
+ '';
workspace.refreshFeatureDataSources();
+ if (newId) {
+ setPendingAttachFdsId(newId);
+ }
} catch (err) {
console.error('Failed to add feature source to chat:', err);
}
@@ -521,6 +534,8 @@ export const WorkspacePage: React.FC = ({ persistentInstance
onDataSourceDrop={_handleDataSourceDrop}
pendingAttachDsId={pendingAttachDsId}
onPendingAttachDsConsumed={() => setPendingAttachDsId('')}
+ pendingAttachFdsId={pendingAttachFdsId}
+ onPendingAttachFdsConsumed={() => setPendingAttachFdsId('')}
onPasteAsFile={_uploadAndAttach}
draftAppend={draftAppend}
onDraftAppendConsumed={() => setDraftAppend('')}