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 (
@@ -175,6 +183,16 @@ const ConnectionPicker: React.FC = ({ param, value, onChange ))} + {!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 ( +
+ +
+ 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 }} + /> + +
+ + {open && hasConnection && ( +
+ {error &&
{error}
} +
+ {t('Seite:')} + + +
+ + {selectedSite && ( + <> +
+ {t('Pfad:')} {selectedSite.path}/{currentPath || {t('(Stammverzeichnis)')}} +
+
+ {currentPath && ( + + )} + +
+
+ {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} + + +
+ ))} +
+ + )} +
+ )} +
+ ); +}; + 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 = () => { ))} + + + {importing && importJob && ( +
+
+
+
+
+ {importJob.progress}% {importJob.progressMessage ? `· ${importJob.progressMessage}` : ''} +
+
+ )}