diff --git a/src/App.tsx b/src/App.tsx index 15019ad..5c4d66b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -43,8 +43,7 @@ import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandat import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing'; -import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage'; -import { WorkflowAutomationPage } from './pages/WorkflowAutomationPage'; +import { WorkflowAutomationPage } from './pages/workflowAutomation/WorkflowAutomationHubPage'; import { RagInventoryPage } from './pages/RagInventoryPage'; import { ComplianceAuditPage } from './pages/ComplianceAuditPage'; function App() { @@ -124,11 +123,6 @@ function App() { } /> - {/* ============================================== */} - {/* AUTOMATIONS DASHBOARD */} - {/* ============================================== */} - } /> - {/* ============================================== */} {/* WORKFLOW AUTOMATION (System-Komponente) */} {/* ============================================== */} @@ -143,6 +137,7 @@ function App() { } /> } /> + {/* ============================================== */} {/* FEATURE-INSTANZ ROUTES */} {/* /mandates/:mandateId/:featureCode/:instanceId */} diff --git a/src/api/workflowApi.ts b/src/api/workflowApi.ts index 8954c44..ba1cc89 100644 --- a/src/api/workflowApi.ts +++ b/src/api/workflowApi.ts @@ -1,989 +1,135 @@ /** - * Workflow API (GraphicalEditor) - * Node types and graph execution for n8n-style flows. - */ - -import type { ApiRequestOptions } from '../hooks/useApi'; - -const LOG = '[Workflow]'; - -// ============================================================================ -// TYPES -// ============================================================================ - -export interface NodeTypeParameter { - name: string; - type: string; - required?: boolean; - description?: string; - default?: unknown; - frontendType?: string; - frontendOptions?: Record; - options?: unknown[]; - validation?: Record; -} - -export interface PortField { - name: string; - type: string; - /** Plain string or per-language map from the API catalog. */ - description: string | Record; - required: boolean; - enumValues?: string[] | null; - /** When true, surface at the top of the DataPicker as the most common/recommended pick. */ - recommended?: boolean; - /** Human label from portTypeCatalog (backend). Preferred over technical path in DataPicker. */ - pickerLabel?: string | null; - /** Backend: segment for one list element (between List field and nested field). */ - pickerItemLabel?: string | null; -} - -export interface PortSchema { - name: string; - fields: PortField[]; -} - -/** One pickable binding — defined on ``outputPorts[n].dataPickOptions`` (authoritative list from gateway). */ -export interface DataPickOption { - path: (string | number)[]; - pickerLabel: string; - detail?: string; - recommended?: boolean; - iterable?: boolean; - /** For display and optional strict compatibility (e.g. str, Any). */ - type?: string; -} - -/** @deprecated Prefer ``outputPorts[].dataPickOptions``; kept for older payloads. */ -export type OutputPickHint = DataPickOption; - -export interface InputPortDef { - accepts: string[]; -} - -/** Graph-defined output schema (e.g. form fields from node parameters). */ -export interface GraphDefinedSchemaRef { - kind: 'fromGraph'; - parameter: string; -} - -export interface OutputPortDef { - schema: string | GraphDefinedSchemaRef; - dynamic?: boolean; - deriveFrom?: string; - /** - * When set, DataPicker uses **only** this list for that port (no portTypeCatalog expansion). - * Authoritative, like `parameters` for node configuration. - */ - dataPickOptions?: DataPickOption[]; -} - -export interface NodeType { - id: string; - category: string; - label: string; - description: string; - parameters: NodeTypeParameter[]; - inputs: number; - outputs: number; - outputLabels?: string[]; - executor: string; - inputPorts?: Record; - outputPorts?: Record; - meta?: { - icon?: string; - color?: string; - /** True if this node performs an LLM / AI call (credits). */ - usesAi?: boolean; - method?: string; - action?: string; - }; -} -export interface NodeTypeCategory { - id: string; - label: Record | string; -} - -export interface SystemVariable { - type: string; - description: string; -} - -/** Single form field type with its canonical port primitive. Delivered by GET /node-types. */ -export interface FormFieldType { - id: string; - label: string; - portType: string; -} - -export interface ConditionOperatorDef { - id: string; - label: string; - labelKey?: string; - needsValue: boolean; - valueInput?: { kind: string; options?: string[] }; -} - -export interface NodeTypesResponse { - nodeTypes: NodeType[]; - categories: NodeTypeCategory[]; - portTypeCatalog?: Record; - conditionOperatorCatalog?: Record; - systemVariables?: Record; - formFieldTypes?: FormFieldType[]; -} - -export interface Automation2GraphNode { - id: string; - type: string; - parameters?: Record; - inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>; - outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>; -} - -export interface Automation2Connection { - source: string; - target: string; - sourceOutput?: number; - targetInput?: number; -} - -export interface Automation2Graph { - nodes: Automation2GraphNode[]; - connections: Automation2Connection[]; -} - -export interface ExecuteGraphResponse { - success: boolean; - nodeOutputs?: Record; - error?: string; - /** Soft, non-blocking message displayed alongside a successful response. - * Used e.g. by the Save flow to surface "Gespeichert mit X Pflicht-Fehlern" - * without flipping `success` to `false`. */ - warning?: string; - stopped?: boolean; - failedNode?: string; - paused?: boolean; - taskId?: string; - runId?: string; - nodeId?: string; -} - -/** Entry point / start configured outside the canvas (manual, form, schedule, …) */ -export interface WorkflowEntryPoint { - id: string; - kind: string; - category: 'on_demand' | 'always_on'; - enabled: boolean; - title: Record | string; - description?: Record; - config: Record; -} - -export interface Automation2Workflow { - id: string; - label: string; - graph: Automation2Graph; - active?: boolean; - /** Target feature instance for execution data scope (NULL for templates) */ - targetFeatureInstanceId?: string | null; - /** Entry points (Starts) — how this workflow may be invoked */ - invocations?: WorkflowEntryPoint[]; - /** Enriched: run count */ - runCount?: number; - /** Enriched: has active (running/paused) run */ - isRunning?: boolean; - /** Enriched: status of active run */ - runStatus?: string; - /** Enriched: nodeId where workflow is stuck (paused) */ - stuckAtNodeId?: string; - /** Enriched: human-readable label for stuck node */ - stuckAtNodeLabel?: string; - /** From PowerOnModel base — record creation timestamp (seconds) */ - sysCreatedAt?: number; - /** Enriched: last run started timestamp (seconds) */ - lastStartedAt?: number; -} - -// ============================================================================ -// AUTO-PREFIX TYPES (Greenfield) -// ============================================================================ - -export type AutoWorkflowStatus = 'draft' | 'published' | 'archived'; -export type AutoRunStatus = 'running' | 'paused' | 'completed' | 'failed' | 'cancelled'; -export type AutoStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped'; -export type AutoTaskStatus = 'pending' | 'completed' | 'cancelled' | 'expired'; -export type AutoTemplateScope = 'user' | 'instance' | 'mandate' | 'system'; - -export interface AutoVersion { - id: string; - workflowId: string; - versionNumber: number; - status: AutoWorkflowStatus; - graph: Automation2Graph; - invocations?: WorkflowEntryPoint[]; - publishedAt?: number; - publishedBy?: string; -} - -export interface AutoRun { - id: string; - workflowId: string; - versionId?: string; - status: AutoRunStatus; - trigger?: Record; - startedAt?: number; - completedAt?: number; - nodeOutputs?: Record; - currentNodeId?: string; - resumeContext?: Record; - error?: string; - costTokens?: number; - costCredits?: number; -} - -export interface AutoWorkflow { - id: string; - mandateId: string; - featureInstanceId: string; - label: string; - description?: string; - tags?: string[]; - isTemplate: boolean; - templateSourceId?: string; - templateScope?: AutoTemplateScope; - sharedReadOnly?: boolean; - currentVersionId?: string; - active: boolean; - eventId?: string; - notifyOnFailure?: boolean; - graph: Automation2Graph; - invocations?: WorkflowEntryPoint[]; - sysCreatedBy?: string; - sysCreatedAt?: number; - sysModifiedBy?: string; - sysModifiedAt?: number; -} - -export interface AutoTask { - id: string; - runId: string; - workflowId: string; - nodeId: string; - nodeType: string; - config: Record; - assigneeId?: string; - status: AutoTaskStatus; - result?: Record; - expiresAt?: number; - sysCreatedAt?: number; -} - -export interface AutoStepLog { - id: string; - runId: string; - nodeId: string; - nodeType: string; - status: AutoStepStatus; - inputSnapshot?: Record; - output?: Record; - error?: string; - startedAt?: number; - completedAt?: number; - durationMs?: number; - tokensUsed?: number; - retryCount?: number; -} - -// ============================================================================ -// API FUNCTIONS -// ============================================================================ - -export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; - -/** - * Fetch node types for the flow builder (backend-driven). - * GET /api/workflows/{instanceId}/node-types?language=de - */ -export async function fetchNodeTypes( - request: ApiRequestFunction, - instanceId: string, - language = 'de' -): Promise { - console.log(`${LOG} fetchNodeTypes: instanceId=${instanceId} language=${language}`); - const data = await request({ - url: `/api/workflows/${instanceId}/node-types`, - method: 'get', - params: { language }, - }); - const nodeTypes = data?.nodeTypes ?? []; - const categories = data?.categories ?? []; - const portTypeCatalog = data?.portTypeCatalog ?? undefined; - const conditionOperatorCatalog = data?.conditionOperatorCatalog ?? undefined; - const systemVariables = data?.systemVariables ?? undefined; - const formFieldTypes = data?.formFieldTypes ?? undefined; - console.log( - `${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` + - `${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` + - `${conditionOperatorCatalog ? Object.keys(conditionOperatorCatalog).length : 0} conditionKinds, ` + - `${systemVariables ? Object.keys(systemVariables).length : 0} sysVars, ` + - `${formFieldTypes ? formFieldTypes.length : 0} formFieldTypes` - ); - return { nodeTypes, categories, portTypeCatalog, conditionOperatorCatalog, systemVariables, formFieldTypes }; -} - -export interface UpstreamPathEntry { - producerNodeId: string; - producerLabel?: string; - path: (string | number)[]; - type: string; - label: string; - scopeOrigin: 'data' | 'loop' | 'system'; - valueKind?: string; -} - -export interface ConditionMetaResponse { - valueKind: string; - operators: ConditionOperatorDef[]; -} - -export interface ConditionMetaRequest { - graph: Automation2Graph; - nodeId?: string; - ref: { type: 'ref'; nodeId: string; path: (string | number)[] }; -} - -/** - * POST /api/workflows/{instanceId}/condition-meta — operators for a DataRef (If/Else). - */ -export async function fetchConditionMeta( - request: ApiRequestFunction, - instanceId: string, - body: ConditionMetaRequest, - language = 'de' -): Promise { - const data = await request({ - url: `/api/workflows/${instanceId}/condition-meta`, - method: 'post', - params: { language }, - data: body, - }); - return { - valueKind: String(data?.valueKind ?? 'unknown'), - operators: (data?.operators ?? []) as ConditionOperatorDef[], - }; -} - -/** - * POST /api/workflows/{instanceId}/upstream-paths — pickable upstream paths for DataPicker / AI. - */ -export async function postUpstreamPaths( - request: ApiRequestFunction, - instanceId: string, - graph: Automation2Graph, - nodeId: string -): Promise<{ paths: UpstreamPathEntry[] }> { - const data = await request({ - url: `/api/workflows/${instanceId}/upstream-paths`, - method: 'post', - data: { graph, nodeId }, - }); - return { paths: (data?.paths ?? []) as UpstreamPathEntry[] }; -} - -/** Scope-aware data sources for the DataPicker — all loop-scope logic lives on the backend. */ -export interface GraphDataSources { - /** Ancestor node IDs that are valid sources (loop body nodes excluded when on Done branch). */ - availableSourceIds: string[]; - /** Maps nodeId → output port index to use instead of 0 (e.g. loop node on Done branch → 1). */ - portIndexOverrides: Record; - /** IDs of flow.loop nodes whose body the current node is inside (show currentItem etc.). */ - loopBodyContextIds: string[]; -} - -/** - * POST /api/workflows/{instanceId}/graph-data-sources + * Workflow API (WorkflowAutomation) — LEGACY RE-EXPORT LAYER * - * Returns scope-aware source list so the DataPicker needs zero graph-traversal logic. - * The graph connections must use { source, target, sourceOutput?, targetInput? } format. - */ -export async function fetchGraphDataSources( - request: ApiRequestFunction, - instanceId: string, - nodeId: string, - nodes: Array<{ id: string; type?: string }>, - connections: Array<{ source: string; target: string; sourceOutput?: number; targetInput?: number }>, -): Promise { - const data = await request({ - url: `/api/workflows/${instanceId}/graph-data-sources`, - method: 'post', - data: { nodeId, graph: { nodes, connections } }, - }); - return { - availableSourceIds: data?.availableSourceIds ?? [], - portIndexOverrides: data?.portIndexOverrides ?? {}, - loopBodyContextIds: data?.loopBodyContextIds ?? [], - }; -} - -/** GET saved workflow graph variant of upstream-paths (requires workflowId). */ -export async function getUpstreamPathsSaved( - request: ApiRequestFunction, - instanceId: string, - workflowId: string, - nodeId: string -): Promise<{ paths: UpstreamPathEntry[] }> { - const data = await request({ - url: `/api/workflows/${instanceId}/upstream-paths/${encodeURIComponent(nodeId)}`, - method: 'get', - params: { workflowId }, - }); - return { paths: (data?.paths ?? []) as UpstreamPathEntry[] }; -} - -/** - * Execute an automation2 graph. - * POST /api/workflows/{instanceId}/execute - */ -export interface ExecuteGraphOptions { - /** Use a configured start on the saved workflow */ - entryPointId?: string; - /** Full run envelope (overrides entry point mapping) */ - runEnvelope?: Record; - /** Merged into envelope.payload */ - payload?: Record; -} - -export async function executeGraph( - request: ApiRequestFunction, - instanceId: string, - graph: Automation2Graph, - workflowId?: string, - options?: ExecuteGraphOptions -): Promise { - console.log( - `${LOG} executeGraph request: instanceId=${instanceId} workflowId=${workflowId} nodes=${graph.nodes.length} connections=${graph.connections.length}`, - { nodes: graph.nodes, connections: graph.connections, options } - ); - const start = performance.now(); - try { - const data: Record = { graph, workflowId }; - if (options?.entryPointId) data.entryPointId = options.entryPointId; - if (options?.runEnvelope) data.runEnvelope = options.runEnvelope; - if (options?.payload && Object.keys(options.payload).length > 0) data.payload = options.payload; - const result = await request({ - url: `/api/workflows/${instanceId}/execute`, - method: 'post', - data, - }); - const ms = Math.round(performance.now() - start); - console.log( - `${LOG} executeGraph response (${ms}ms): success=${result?.success} error=${result?.error ?? 'none'} nodeOutputs_keys=${Object.keys(result?.nodeOutputs ?? {}).join(',')} failedNode=${result?.failedNode ?? '-'}`, - result - ); - return result; - } catch (err) { - const ms = Math.round(performance.now() - start); - console.error( - `${LOG} executeGraph FAILED (${ms}ms): instanceId=${instanceId}`, - err - ); - throw err; - } -} - -// ------------------------------------------------------------------------- -// Workflows CRUD -// ------------------------------------------------------------------------- - -export async function fetchWorkflows( - request: ApiRequestFunction, - instanceId: string, - params?: { active?: boolean; pagination?: any } -): Promise { - const queryParams: Record = {}; - if (params?.active !== undefined) queryParams.active = params.active; - if (params?.pagination) queryParams.pagination = JSON.stringify(params.pagination); - const data = await request({ - url: `/api/workflows/${instanceId}/workflows`, - method: 'get', - params: Object.keys(queryParams).length > 0 ? queryParams : undefined, - }); - if (data?.items && data?.pagination) return data; - return data?.workflows ?? []; -} - -export async function fetchWorkflow( - request: ApiRequestFunction, - instanceId: string, - workflowId: string -): Promise { - return await request({ - url: `/api/workflows/${instanceId}/workflows/${workflowId}`, - method: 'get', - }); -} - -export async function createWorkflow( - request: ApiRequestFunction, - instanceId: string, - body: { - label: string; - graph: Automation2Graph; - invocations?: WorkflowEntryPoint[]; - targetFeatureInstanceId?: string | null; - } -): Promise { - return await request({ - url: `/api/workflows/${instanceId}/workflows`, - method: 'post', - data: body, - }); -} - -export async function updateWorkflow( - request: ApiRequestFunction, - instanceId: string, - workflowId: string, - body: { - label?: string; - graph?: Automation2Graph; - invocations?: WorkflowEntryPoint[]; - active?: boolean; - notifyOnFailure?: boolean; - targetFeatureInstanceId?: string | null; - } -): Promise { - return await request({ - url: `/api/workflows/${instanceId}/workflows/${workflowId}`, - method: 'put', - data: body, - }); -} - -export async function deleteWorkflow( - request: ApiRequestFunction, - instanceId: string, - workflowId: string -): Promise { - await request({ - url: `/api/workflows/${instanceId}/workflows/${workflowId}`, - method: 'delete', - }); -} - -// ------------------------------------------------------------------------- -// 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 + * All functions have been migrated to workflowAutomationApi.ts (mandate-scoped). + * This file re-exports them so existing consumers still resolve. * - * 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. + * Only ClickUp-specific functions and `deleteSystemWorkflow` remain here + * because their endpoints are not yet covered by the new unified API. */ -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' - ); -} +// ============================================================================ +// RE-EXPORTS — Types & Interfaces +// ============================================================================ -/** 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}`; -} +export type { + NodeTypeParameter, + PortField, + PortSchema, + DataPickOption, + OutputPickHint, + InputPortDef, + GraphDefinedSchemaRef, + OutputPortDef, + NodeType, + NodeTypeCategory, + SystemVariable, + FormFieldType, + ConditionOperatorDef, + NodeTypesResponse, + Automation2GraphNode, + Automation2Connection, + Automation2Graph, + ExecuteGraphResponse, + WorkflowEntryPoint, + Automation2Workflow, + AutoVersion, + AutoRun, + AutoWorkflow, + AutoTask, + AutoStepLog, + UpstreamPathEntry, + ConditionMetaResponse, + ConditionMetaRequest, + GraphDataSources, + ExecuteGraphOptions, + Automation2Run, + CompletedRun, + Automation2Task, + AutoWorkflowTemplate, + UserConnection, + ConnectionService, + BrowseEntry, + WorkflowMetrics, + WorkflowFileEnvelope, + ImportWorkflowResponse, + ImportWorkflowOptions, + ExportWorkflowResult, + WorkspaceRun, + WorkspaceRunDetail, + ApiRequestFunction, +} from './workflowAutomationApi'; -/** Delete by workflow ID only (Automations dashboard / orphan rows without featureInstanceId). */ -export async function deleteSystemWorkflow( - request: ApiRequestFunction, - workflowId: string, -): Promise { - await request({ - url: `/api/system/workflow-runs/workflows/${workflowId}`, - method: 'delete', - }); -} +export type { + AutoWorkflowStatus, + AutoRunStatus, + AutoStepStatus, + AutoTaskStatus, + AutoTemplateScope, +} from './workflowAutomationApi'; -export interface Automation2Run { - id: string; - workflowId: string; - status: string; - nodeOutputs?: Record; - currentNodeId?: string; -} +// ============================================================================ +// RE-EXPORTS — Constants +// ============================================================================ -export async function fetchWorkflowRuns( - request: ApiRequestFunction, - instanceId: string, - workflowId: string -): Promise { - const data = await request({ - url: `/api/workflows/${instanceId}/workflows/${workflowId}/runs`, - method: 'get', - }); - return data?.runs ?? []; -} +export { + WORKFLOW_FILE_SCHEMA_VERSION, + WORKFLOW_FILE_KIND, + WORKFLOW_FILE_EXTENSION, +} from './workflowAutomationApi'; -export interface CompletedRun extends Automation2Run { - workflowLabel?: string; - sysModifiedAt?: number; - sysCreatedAt?: number; -} +// ============================================================================ +// RE-EXPORTS — API Functions (canonical implementations in workflowAutomationApi) +// ============================================================================ -export async function fetchCompletedRuns( - request: ApiRequestFunction, - instanceId: string, - limit = 20 -): Promise { - const data = await request({ - url: `/api/workflows/${instanceId}/runs/completed`, - method: 'get', - params: { limit }, - }); - return data?.runs ?? []; -} +export { + fetchNodeTypes, + fetchConditionMeta, + postUpstreamPaths, + fetchGraphDataSources, + getUpstreamPathsSaved, + executeGraph, + fetchWorkflows, + fetchWorkflow, + createWorkflow, + updateWorkflow, + deleteWorkflow, + importWorkflowFromFile, + exportWorkflowToFile, + isWorkflowFileContent, + workflowFileNameFor, + fetchWorkflowRuns, + fetchCompletedRuns, + fetchTasks, + completeTask, + cancelPendingTaskStopRun, + fetchVersions, + createDraftVersion, + publishVersion, + unpublishVersion, + archiveVersion, + fetchTemplates, + createTemplateFromWorkflow, + copyTemplate, + shareTemplate, + fetchConnections, + fetchConnectionServices, + fetchBrowse, + fetchMetrics, + fetchWorkspaceRuns, + fetchRunDetail as fetchWorkspaceRunDetail, +} from './workflowAutomationApi'; -// ------------------------------------------------------------------------- -// Tasks -// ------------------------------------------------------------------------- +// ============================================================================ +// KEPT — ClickUp-specific functions (not in workflow-automation API) +// ============================================================================ -export interface Automation2Task { - id: string; - runId: string; - workflowId: string; - nodeId: string; - nodeType: string; - config: Record; - status: string; - result?: Record; - /** Workflow label (enriched by API) */ - workflowLabel?: string; - /** Unix timestamp ms (from sysCreatedAt) */ - createdAt?: number; - /** Optional due date - configurable in future */ - dueAt?: number; -} +import type { ApiRequestFunction } from './workflowAutomationApi'; -export async function fetchTasks( - request: ApiRequestFunction, - instanceId: string, - params?: { workflowId?: string; status?: string } -): Promise { - const data = await request({ - url: `/api/workflows/${instanceId}/tasks`, - method: 'get', - params, - }); - return data?.tasks ?? []; -} - -export async function completeTask( - request: ApiRequestFunction, - instanceId: string, - taskId: string, - result: Record -): Promise { - return await request({ - url: `/api/workflows/${instanceId}/tasks/${taskId}/complete`, - method: 'post', - data: { result }, - }); -} - -/** Cancel a pending human task and stop its workflow run (Graphical Editor). */ -export async function cancelPendingTaskStopRun( - request: ApiRequestFunction, - instanceId: string, - taskId: string -): Promise<{ success: boolean; runId?: string | null; taskId: string }> { - const data = await request({ - url: `/api/workflows/${instanceId}/tasks/${taskId}/cancel`, - method: 'post', - }); - return { - success: Boolean(data?.success), - runId: data?.runId, - taskId: data?.taskId ?? taskId, - }; -} - -// ------------------------------------------------------------------------- -// Versions (AutoVersion Lifecycle) -// ------------------------------------------------------------------------- - -export async function fetchVersions( - request: ApiRequestFunction, - instanceId: string, - workflowId: string -): Promise { - const data = await request({ - url: `/api/workflows/${instanceId}/workflows/${workflowId}/versions`, - method: 'get', - }); - return data?.versions ?? []; -} - -export async function createDraftVersion( - request: ApiRequestFunction, - instanceId: string, - workflowId: string -): Promise { - return await request({ - url: `/api/workflows/${instanceId}/workflows/${workflowId}/versions/draft`, - method: 'post', - }); -} - -export async function publishVersion( - request: ApiRequestFunction, - instanceId: string, - versionId: string -): Promise { - return await request({ - url: `/api/workflows/${instanceId}/versions/${versionId}/publish`, - method: 'post', - }); -} - -export async function unpublishVersion( - request: ApiRequestFunction, - instanceId: string, - versionId: string -): Promise { - return await request({ - url: `/api/workflows/${instanceId}/versions/${versionId}/unpublish`, - method: 'post', - }); -} - -export async function archiveVersion( - request: ApiRequestFunction, - instanceId: string, - versionId: string -): Promise { - return await request({ - url: `/api/workflows/${instanceId}/versions/${versionId}/archive`, - method: 'post', - }); -} - -// ------------------------------------------------------------------------- -// Templates -// ------------------------------------------------------------------------- - -export interface AutoWorkflowTemplate extends Automation2Workflow { - isTemplate: boolean; - templateScope?: AutoTemplateScope; - templateSourceId?: string; - sharedReadOnly?: boolean; -} - -export async function fetchTemplates( - request: ApiRequestFunction, - instanceId: string, - scope?: AutoTemplateScope, - pagination?: any -): Promise { - const queryParams: Record = {}; - if (scope) queryParams.scope = scope; - if (pagination) queryParams.pagination = JSON.stringify(pagination); - const data = await request({ - url: `/api/workflows/${instanceId}/templates`, - method: 'get', - params: Object.keys(queryParams).length > 0 ? queryParams : undefined, - }); - if (data?.items && data?.pagination) return data; - return data?.templates ?? []; -} - -export async function createTemplateFromWorkflow( - request: ApiRequestFunction, - instanceId: string, - workflowId: string, - scope: AutoTemplateScope = 'user' -): Promise { - return await request({ - url: `/api/workflows/${instanceId}/templates/from-workflow`, - method: 'post', - data: { workflowId, scope }, - }); -} - -export async function copyTemplate( - request: ApiRequestFunction, - instanceId: string, - templateId: string -): Promise { - return await request({ - url: `/api/workflows/${instanceId}/templates/${templateId}/copy`, - method: 'post', - }); -} - -export async function shareTemplate( - request: ApiRequestFunction, - instanceId: string, - templateId: string, - scope: AutoTemplateScope -): Promise { - return await request({ - url: `/api/workflows/${instanceId}/templates/${templateId}/share`, - method: 'post', - data: { scope }, - }); -} - -// ------------------------------------------------------------------------- -// Connections and Browse (for Email/SharePoint node config) -// ------------------------------------------------------------------------- - -export interface UserConnection { - id: string; - authority: string; - externalUsername?: string; - externalEmail?: string; - status: string; -} - -export async function fetchConnections( - request: ApiRequestFunction, - instanceId: string -): Promise { - const data = await request({ - url: `/api/workflows/${instanceId}/connections`, - method: 'get', - }); - return data?.connections ?? []; -} - -export interface ConnectionService { - service: string; - label: string; - icon: string; -} - -/** Encode connection id/reference for URL path segments (may contain spaces/colons). */ function _encodedConnectionId(connectionId: string): string { return encodeURIComponent(connectionId); } -export async function fetchConnectionServices( - request: ApiRequestFunction, - instanceId: string, - connectionId: string -): Promise { - const data = await request({ - url: `/api/workflows/${instanceId}/connections/${_encodedConnectionId(connectionId)}/services`, - method: 'get', - }); - return data?.services ?? []; -} - -export interface BrowseEntry { - name: string; - path: string; - isFolder: boolean; - size?: number; - mimeType?: string; - metadata?: Record; -} - -export async function fetchBrowse( - request: ApiRequestFunction, - instanceId: string, - connectionId: string, - service: string, - path = '/' -): Promise<{ items: BrowseEntry[]; path: string; service: string }> { - const data = await request({ - url: `/api/workflows/${instanceId}/connections/${_encodedConnectionId(connectionId)}/browse`, - method: 'get', - params: { service, path }, - }); - return { items: data?.items ?? [], path: data?.path ?? path, service: data?.service ?? service }; -} - /** ClickUp GET /task/{taskId} — list.id for resolving list-scoped fields when only task id is known. */ export async function fetchClickupTask( request: ApiRequestFunction, @@ -1064,32 +210,7 @@ export async function fetchClickupListTasks( } & Record; } -// ------------------------------------------------------------------------- -// Monitoring / Metrics -// ------------------------------------------------------------------------- - -export interface WorkflowMetrics { - workflowCount: number; - activeWorkflows: number; - totalRuns: number; - runsByStatus: Record; - totalTasks: number; - tasksByStatus: Record; - totalTokens: number; - totalCredits: number; -} - -export async function fetchMetrics( - request: ApiRequestFunction, - instanceId: string -): Promise { - return await request({ - url: `/api/workflows/${instanceId}/metrics`, - method: 'get', - }); -} - -/** Paginated tasks in a list — for ClickUp relationship dropdowns and input.form „ClickUp-Aufgabe“. */ +/** Paginated tasks in a list — for ClickUp relationship dropdowns and input.form „ClickUp-Aufgabe". */ export async function loadClickupListTasksForDropdown( request: ApiRequestFunction, connectionId: string, @@ -1130,94 +251,17 @@ export async function loadClickupListTasksForDropdown( return acc; } - // ============================================================================ -// AUTOMATION WORKSPACE API (user-facing run workspace) +// KEPT — deleteSystemWorkflow (uses /api/system/... not covered by new API) // ============================================================================ -export interface WorkspaceRun { - id: string; - workflowId: string; - workflowLabel?: string; - status: string; - startedAt?: number; - completedAt?: number; - ownerId?: string; - mandateId?: string; - mandateLabel?: string; - targetFeatureInstanceId?: string; - targetInstanceLabel?: string; - costTokens?: number; - costCredits?: number; - error?: string; -} - -export interface WorkspaceRunDetail { - run: WorkspaceRun & { nodeOutputs?: Record }; - workflow: { - id: string; - label: string; - targetFeatureInstanceId?: string; - featureInstanceId?: string; - tags?: string[]; - } | null; - steps: Array<{ - id: string; - runId: string; - nodeId: string; - nodeType: string; - status: string; - inputSnapshot?: Record; - output?: Record; - inputFiles?: Array<{ id: string; fileName?: string }>; - outputFiles?: Array<{ id: string; fileName?: string }>; - error?: string; - startedAt?: number; - completedAt?: number; - durationMs?: number; - tokensUsed?: number; - retryCount?: number; - }>; - files: Array<{ - id: string; - fileName?: string; - contentType?: string; - sizeBytes?: number; - }>; - unassignedFiles?: Array<{ - id: string; - fileName?: string; - }>; -} - -export async function fetchWorkspaceRuns( +/** Delete by workflow ID only (Automations dashboard / orphan rows without featureInstanceId). */ +export async function deleteSystemWorkflow( request: ApiRequestFunction, - params: { - scope?: 'mine' | 'mandate'; - status?: string; - targetInstanceId?: string; - workflowId?: string; - limit?: number; - offset?: number; - } = {}, -): Promise<{ runs: WorkspaceRun[]; total: number }> { - const query = new URLSearchParams(); - if (params.scope) query.set('scope', params.scope); - if (params.status) query.set('status', params.status); - if (params.targetInstanceId) query.set('targetInstanceId', params.targetInstanceId); - if (params.workflowId) query.set('workflowId', params.workflowId); - if (params.limit) query.set('limit', String(params.limit)); - if (params.offset) query.set('offset', String(params.offset)); - const qs = query.toString(); - const url = `/api/automations/runs${qs ? `?${qs}` : ''}`; - const resp = await request({ url, method: 'get' }); - return resp as { runs: WorkspaceRun[]; total: number }; -} - -export async function fetchWorkspaceRunDetail( - request: ApiRequestFunction, - runId: string, -): Promise { - const resp = await request({ url: `/api/automations/runs/${runId}/detail`, method: 'get' }); - return resp as WorkspaceRunDetail; + workflowId: string, +): Promise { + await request({ + url: `/api/workflow-automation/workflows/${workflowId}`, + method: 'delete', + }); } diff --git a/src/api/workflowAutomationApi.ts b/src/api/workflowAutomationApi.ts new file mode 100644 index 0000000..fbb1632 --- /dev/null +++ b/src/api/workflowAutomationApi.ts @@ -0,0 +1,1193 @@ +/** + * Workflow Automation API (mandate-scoped) + * + * Replaces instance-scoped /api/workflows/{instanceId}/... + * with the unified /api/workflow-automation/... base path. + * + * Also unifies the former /api/system/workflow-runs/... and + * /api/automations/runs/... endpoints under the same base. + */ + +import type { ApiRequestOptions } from '../hooks/useApi'; + +const LOG = '[WorkflowAutomation]'; +const BASE = '/api/workflow-automation'; + +// ============================================================================ +// TYPES +// ============================================================================ + +export interface NodeTypeParameter { + name: string; + type: string; + required?: boolean; + description?: string; + default?: unknown; + frontendType?: string; + frontendOptions?: Record; + options?: unknown[]; + validation?: Record; +} + +export interface PortField { + name: string; + type: string; + /** Plain string or per-language map from the API catalog. */ + description: string | Record; + required: boolean; + enumValues?: string[] | null; + /** When true, surface at the top of the DataPicker as the most common/recommended pick. */ + recommended?: boolean; + /** Human label from portTypeCatalog (backend). Preferred over technical path in DataPicker. */ + pickerLabel?: string | null; + /** Backend: segment for one list element (between List field and nested field). */ + pickerItemLabel?: string | null; +} + +export interface PortSchema { + name: string; + fields: PortField[]; +} + +/** One pickable binding — defined on ``outputPorts[n].dataPickOptions`` (authoritative list from gateway). */ +export interface DataPickOption { + path: (string | number)[]; + pickerLabel: string; + detail?: string; + recommended?: boolean; + iterable?: boolean; + /** For display and optional strict compatibility (e.g. str, Any). */ + type?: string; +} + +/** @deprecated Prefer ``outputPorts[].dataPickOptions``; kept for older payloads. */ +export type OutputPickHint = DataPickOption; + +export interface InputPortDef { + accepts: string[]; +} + +/** Graph-defined output schema (e.g. form fields from node parameters). */ +export interface GraphDefinedSchemaRef { + kind: 'fromGraph'; + parameter: string; +} + +export interface OutputPortDef { + schema: string | GraphDefinedSchemaRef; + dynamic?: boolean; + deriveFrom?: string; + /** + * When set, DataPicker uses **only** this list for that port (no portTypeCatalog expansion). + * Authoritative, like `parameters` for node configuration. + */ + dataPickOptions?: DataPickOption[]; +} + +export interface NodeType { + id: string; + category: string; + label: string; + description: string; + parameters: NodeTypeParameter[]; + inputs: number; + outputs: number; + outputLabels?: string[]; + executor: string; + inputPorts?: Record; + outputPorts?: Record; + meta?: { + icon?: string; + color?: string; + /** True if this node performs an LLM / AI call (credits). */ + usesAi?: boolean; + method?: string; + action?: string; + }; +} +export interface NodeTypeCategory { + id: string; + label: Record | string; +} + +export interface SystemVariable { + type: string; + description: string; +} + +/** Single form field type with its canonical port primitive. Delivered by GET /node-types. */ +export interface FormFieldType { + id: string; + label: string; + portType: string; +} + +export interface ConditionOperatorDef { + id: string; + label: string; + labelKey?: string; + needsValue: boolean; + valueInput?: { kind: string; options?: string[] }; +} + +export interface NodeTypesResponse { + nodeTypes: NodeType[]; + categories: NodeTypeCategory[]; + portTypeCatalog?: Record; + conditionOperatorCatalog?: Record; + systemVariables?: Record; + formFieldTypes?: FormFieldType[]; +} + +export interface Automation2GraphNode { + id: string; + type: string; + parameters?: Record; + inputPorts?: Array<{ name: string; schema: string; accepts?: string[] }>; + outputPorts?: Array<{ name: string; schema: string | GraphDefinedSchemaRef }>; +} + +export interface Automation2Connection { + source: string; + target: string; + sourceOutput?: number; + targetInput?: number; +} + +export interface Automation2Graph { + nodes: Automation2GraphNode[]; + connections: Automation2Connection[]; +} + +export interface ExecuteGraphResponse { + success: boolean; + nodeOutputs?: Record; + error?: string; + /** Soft, non-blocking message displayed alongside a successful response. */ + warning?: string; + stopped?: boolean; + failedNode?: string; + paused?: boolean; + taskId?: string; + runId?: string; + nodeId?: string; +} + +/** Entry point / start configured outside the canvas (manual, form, schedule, …) */ +export interface WorkflowEntryPoint { + id: string; + kind: string; + category: 'on_demand' | 'always_on'; + enabled: boolean; + title: Record | string; + description?: Record; + config: Record; +} + +export interface Automation2Workflow { + id: string; + label: string; + graph: Automation2Graph; + active?: boolean; + /** Target feature instance for execution data scope (NULL for templates) */ + targetFeatureInstanceId?: string | null; + /** Entry points (Starts) — how this workflow may be invoked */ + invocations?: WorkflowEntryPoint[]; + /** Enriched: run count */ + runCount?: number; + /** Enriched: has active (running/paused) run */ + isRunning?: boolean; + /** Enriched: status of active run */ + runStatus?: string; + /** Enriched: nodeId where workflow is stuck (paused) */ + stuckAtNodeId?: string; + /** Enriched: human-readable label for stuck node */ + stuckAtNodeLabel?: string; + /** From PowerOnModel base — record creation timestamp (seconds) */ + sysCreatedAt?: number; + /** Enriched: last run started timestamp (seconds) */ + lastStartedAt?: number; +} + +// ============================================================================ +// AUTO-PREFIX TYPES (Greenfield) +// ============================================================================ + +export type AutoWorkflowStatus = 'draft' | 'published' | 'archived'; +export type AutoRunStatus = 'running' | 'paused' | 'completed' | 'failed' | 'cancelled'; +export type AutoStepStatus = 'pending' | 'running' | 'completed' | 'failed' | 'skipped'; +export type AutoTaskStatus = 'pending' | 'completed' | 'cancelled' | 'expired'; +export type AutoTemplateScope = 'user' | 'instance' | 'mandate' | 'system'; + +export interface AutoVersion { + id: string; + workflowId: string; + versionNumber: number; + status: AutoWorkflowStatus; + graph: Automation2Graph; + invocations?: WorkflowEntryPoint[]; + publishedAt?: number; + publishedBy?: string; +} + +export interface AutoRun { + id: string; + workflowId: string; + versionId?: string; + status: AutoRunStatus; + trigger?: Record; + startedAt?: number; + completedAt?: number; + nodeOutputs?: Record; + currentNodeId?: string; + resumeContext?: Record; + error?: string; + costTokens?: number; + costCredits?: number; +} + +export interface AutoWorkflow { + id: string; + mandateId: string; + featureInstanceId: string; + label: string; + description?: string; + tags?: string[]; + isTemplate: boolean; + templateSourceId?: string; + templateScope?: AutoTemplateScope; + sharedReadOnly?: boolean; + currentVersionId?: string; + active: boolean; + eventId?: string; + notifyOnFailure?: boolean; + graph: Automation2Graph; + invocations?: WorkflowEntryPoint[]; + sysCreatedBy?: string; + sysCreatedAt?: number; + sysModifiedBy?: string; + sysModifiedAt?: number; +} + +export interface AutoTask { + id: string; + runId: string; + workflowId: string; + nodeId: string; + nodeType: string; + config: Record; + assigneeId?: string; + status: AutoTaskStatus; + result?: Record; + expiresAt?: number; + sysCreatedAt?: number; +} + +export interface AutoStepLog { + id: string; + runId: string; + nodeId: string; + nodeType: string; + status: AutoStepStatus; + inputSnapshot?: Record; + output?: Record; + error?: string; + startedAt?: number; + completedAt?: number; + durationMs?: number; + tokensUsed?: number; + retryCount?: number; +} + +// ============================================================================ +// ADDITIONAL TYPES +// ============================================================================ + +export interface UpstreamPathEntry { + producerNodeId: string; + producerLabel?: string; + path: (string | number)[]; + type: string; + label: string; + scopeOrigin: 'data' | 'loop' | 'system'; + valueKind?: string; +} + +export interface ConditionMetaResponse { + valueKind: string; + operators: ConditionOperatorDef[]; +} + +export interface ConditionMetaRequest { + graph: Automation2Graph; + nodeId?: string; + ref: { type: 'ref'; nodeId: string; path: (string | number)[] }; +} + +/** Scope-aware data sources for the DataPicker. */ +export interface GraphDataSources { + /** Ancestor node IDs that are valid sources. */ + availableSourceIds: string[]; + /** Maps nodeId → output port index to use instead of 0. */ + portIndexOverrides: Record; + /** IDs of flow.loop nodes whose body the current node is inside. */ + loopBodyContextIds: string[]; +} + +export interface ExecuteGraphOptions { + /** Use a configured start on the saved workflow */ + entryPointId?: string; + /** Full run envelope (overrides entry point mapping) */ + runEnvelope?: Record; + /** Merged into envelope.payload */ + payload?: Record; +} + +export interface Automation2Run { + id: string; + workflowId: string; + status: string; + nodeOutputs?: Record; + currentNodeId?: string; +} + +export interface CompletedRun extends Automation2Run { + workflowLabel?: string; + sysModifiedAt?: number; + sysCreatedAt?: number; +} + +export interface Automation2Task { + id: string; + runId: string; + workflowId: string; + nodeId: string; + nodeType: string; + config: Record; + status: string; + result?: Record; + /** Workflow label (enriched by API) */ + workflowLabel?: string; + /** Unix timestamp ms (from sysCreatedAt) */ + createdAt?: number; + /** Optional due date — configurable in future */ + dueAt?: number; +} + +export interface AutoWorkflowTemplate extends Automation2Workflow { + isTemplate: boolean; + templateScope?: AutoTemplateScope; + templateSourceId?: string; + sharedReadOnly?: boolean; +} + +export interface UserConnection { + id: string; + authority: string; + externalUsername?: string; + externalEmail?: string; + status: string; +} + +export interface ConnectionService { + service: string; + label: string; + icon: string; +} + +export interface BrowseEntry { + name: string; + path: string; + isFolder: boolean; + size?: number; + mimeType?: string; + metadata?: Record; +} + +export interface WorkflowMetrics { + workflowCount: number; + activeWorkflows: number; + totalRuns: number; + runsByStatus: Record; + totalTasks: number; + tasksByStatus: Record; + totalTokens: number; + totalCredits: number; +} + +// ------------------------------------------------------------------------- +// Workflow file IO (envelopeVersioned, .workflow.json) +// ------------------------------------------------------------------------- + +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; +} + +export interface ExportWorkflowResult { + fileName: string; + envelope: WorkflowFileEnvelope; +} + +// ------------------------------------------------------------------------- +// Workspace run types (user-facing run workspace) +// ------------------------------------------------------------------------- + +export interface WorkspaceRun { + id: string; + workflowId: string; + workflowLabel?: string; + status: string; + startedAt?: number; + completedAt?: number; + ownerId?: string; + mandateId?: string; + mandateLabel?: string; + targetFeatureInstanceId?: string; + targetInstanceLabel?: string; + costTokens?: number; + costCredits?: number; + error?: string; +} + +export interface WorkspaceRunDetail { + run: WorkspaceRun & { nodeOutputs?: Record }; + workflow: { + id: string; + label: string; + targetFeatureInstanceId?: string; + featureInstanceId?: string; + tags?: string[]; + } | null; + steps: Array<{ + id: string; + runId: string; + nodeId: string; + nodeType: string; + status: string; + inputSnapshot?: Record; + output?: Record; + inputFiles?: Array<{ id: string; fileName?: string }>; + outputFiles?: Array<{ id: string; fileName?: string }>; + error?: string; + startedAt?: number; + completedAt?: number; + durationMs?: number; + tokensUsed?: number; + retryCount?: number; + }>; + files: Array<{ + id: string; + fileName?: string; + contentType?: string; + sizeBytes?: number; + }>; + unassignedFiles?: Array<{ + id: string; + fileName?: string; + }>; +} + +// ============================================================================ +// API FUNCTIONS +// ============================================================================ + +export type ApiRequestFunction = (options: ApiRequestOptions) => Promise; + +// ------------------------------------------------------------------------- +// Node types & graph helpers +// ------------------------------------------------------------------------- + +/** + * Fetch node types for the flow builder (backend-driven). + * GET /api/workflow-automation/node-types?language=de + */ +export async function fetchNodeTypes( + request: ApiRequestFunction, + language = 'de' +): Promise { + console.log(`${LOG} fetchNodeTypes: language=${language}`); + const data = await request({ + url: `${BASE}/node-types`, + method: 'get', + params: { language }, + }); + const nodeTypes = data?.nodeTypes ?? []; + const categories = data?.categories ?? []; + const portTypeCatalog = data?.portTypeCatalog ?? undefined; + const conditionOperatorCatalog = data?.conditionOperatorCatalog ?? undefined; + const systemVariables = data?.systemVariables ?? undefined; + const formFieldTypes = data?.formFieldTypes ?? undefined; + console.log( + `${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories, ` + + `${portTypeCatalog ? Object.keys(portTypeCatalog).length : 0} portTypes, ` + + `${conditionOperatorCatalog ? Object.keys(conditionOperatorCatalog).length : 0} conditionKinds, ` + + `${systemVariables ? Object.keys(systemVariables).length : 0} sysVars, ` + + `${formFieldTypes ? formFieldTypes.length : 0} formFieldTypes` + ); + return { nodeTypes, categories, portTypeCatalog, conditionOperatorCatalog, systemVariables, formFieldTypes }; +} + +/** + * POST /api/workflow-automation/condition-meta — operators for a DataRef (If/Else). + */ +export async function fetchConditionMeta( + request: ApiRequestFunction, + body: ConditionMetaRequest, + language = 'de' +): Promise { + const data = await request({ + url: `${BASE}/condition-meta`, + method: 'post', + params: { language }, + data: body, + }); + return { + valueKind: String(data?.valueKind ?? 'unknown'), + operators: (data?.operators ?? []) as ConditionOperatorDef[], + }; +} + +/** + * POST /api/workflow-automation/upstream-paths — pickable upstream paths for DataPicker / AI. + */ +export async function postUpstreamPaths( + request: ApiRequestFunction, + graph: Automation2Graph, + nodeId: string +): Promise<{ paths: UpstreamPathEntry[] }> { + const data = await request({ + url: `${BASE}/upstream-paths`, + method: 'post', + data: { graph, nodeId }, + }); + return { paths: (data?.paths ?? []) as UpstreamPathEntry[] }; +} + +/** + * POST /api/workflow-automation/graph-data-sources + * + * Returns scope-aware source list so the DataPicker needs zero graph-traversal logic. + */ +export async function fetchGraphDataSources( + request: ApiRequestFunction, + nodeId: string, + nodes: Array<{ id: string; type?: string }>, + connections: Array<{ source: string; target: string; sourceOutput?: number; targetInput?: number }>, +): Promise { + const data = await request({ + url: `${BASE}/graph-data-sources`, + method: 'post', + data: { nodeId, graph: { nodes, connections } }, + }); + return { + availableSourceIds: data?.availableSourceIds ?? [], + portIndexOverrides: data?.portIndexOverrides ?? {}, + loopBodyContextIds: data?.loopBodyContextIds ?? [], + }; +} + +/** GET saved workflow graph variant of upstream-paths (requires workflowId). */ +export async function getUpstreamPathsSaved( + request: ApiRequestFunction, + workflowId: string, + nodeId: string +): Promise<{ paths: UpstreamPathEntry[] }> { + const data = await request({ + url: `${BASE}/upstream-paths/${encodeURIComponent(nodeId)}`, + method: 'get', + params: { workflowId }, + }); + return { paths: (data?.paths ?? []) as UpstreamPathEntry[] }; +} + +// ------------------------------------------------------------------------- +// Options (dropdown data for node parameters) +// ------------------------------------------------------------------------- + +/** GET /api/workflow-automation/options/user.connection */ +export async function fetchUserConnectionOptions( + request: ApiRequestFunction, +): Promise> { + const data = await request({ + url: `${BASE}/options/user.connection`, + method: 'get', + }); + return data?.options ?? data ?? []; +} + +/** GET /api/workflow-automation/options/feature.instance */ +export async function fetchFeatureInstanceOptions( + request: ApiRequestFunction, +): Promise> { + const data = await request({ + url: `${BASE}/options/feature.instance`, + method: 'get', + }); + return data?.options ?? data ?? []; +} + +// ------------------------------------------------------------------------- +// Execute +// ------------------------------------------------------------------------- + +/** + * Execute an automation2 graph. + * POST /api/workflow-automation/workflows/{workflowId}/execute + */ +export async function executeGraph( + request: ApiRequestFunction, + graph: Automation2Graph, + workflowId?: string, + options?: ExecuteGraphOptions +): Promise { + console.log( + `${LOG} executeGraph request: workflowId=${workflowId} nodes=${graph.nodes.length} connections=${graph.connections.length}`, + { nodes: graph.nodes, connections: graph.connections, options } + ); + const start = performance.now(); + try { + const body: Record = { graph, workflowId }; + if (options?.entryPointId) body.entryPointId = options.entryPointId; + if (options?.runEnvelope) body.runEnvelope = options.runEnvelope; + if (options?.payload && Object.keys(options.payload).length > 0) body.payload = options.payload; + const url = workflowId + ? `${BASE}/workflows/${workflowId}/execute` + : `${BASE}/execute`; + const result = await request({ url, method: 'post', data: body }); + const ms = Math.round(performance.now() - start); + console.log( + `${LOG} executeGraph response (${ms}ms): success=${result?.success} error=${result?.error ?? 'none'} nodeOutputs_keys=${Object.keys(result?.nodeOutputs ?? {}).join(',')} failedNode=${result?.failedNode ?? '-'}`, + result + ); + return result; + } catch (err) { + const ms = Math.round(performance.now() - start); + console.error(`${LOG} executeGraph FAILED (${ms}ms):`, err); + throw err; + } +} + +// ------------------------------------------------------------------------- +// Workflows CRUD +// ------------------------------------------------------------------------- + +export async function fetchWorkflows( + request: ApiRequestFunction, + params?: { active?: boolean; pagination?: any; mandateId?: string } +): Promise { + const queryParams: Record = {}; + if (params?.active !== undefined) queryParams.active = params.active; + if (params?.pagination) queryParams.pagination = JSON.stringify(params.pagination); + if (params?.mandateId) queryParams.mandateId = params.mandateId; + const data = await request({ + url: `${BASE}/workflows`, + method: 'get', + params: Object.keys(queryParams).length > 0 ? queryParams : undefined, + }); + if (data?.items && data?.pagination) return data; + return data?.workflows ?? []; +} + +export async function fetchWorkflow( + request: ApiRequestFunction, + workflowId: string +): Promise { + return await request({ + url: `${BASE}/workflows/${workflowId}`, + method: 'get', + }); +} + +export async function createWorkflow( + request: ApiRequestFunction, + body: { + label: string; + graph: Automation2Graph; + invocations?: WorkflowEntryPoint[]; + targetFeatureInstanceId?: string | null; + mandateId?: string; + } +): Promise { + return await request({ + url: `${BASE}/workflows`, + method: 'post', + data: body, + }); +} + +export async function updateWorkflow( + request: ApiRequestFunction, + workflowId: string, + body: { + label?: string; + graph?: Automation2Graph; + invocations?: WorkflowEntryPoint[]; + active?: boolean; + notifyOnFailure?: boolean; + targetFeatureInstanceId?: string | null; + } +): Promise { + return await request({ + url: `${BASE}/workflows/${workflowId}`, + method: 'put', + data: body, + }); +} + +export async function deleteWorkflow( + request: ApiRequestFunction, + workflowId: string +): Promise { + await request({ + url: `${BASE}/workflows/${workflowId}`, + method: 'delete', + }); +} + +// ------------------------------------------------------------------------- +// Workflow file IO (envelopeVersioned, .workflow.json) +// ------------------------------------------------------------------------- + +/** POST /api/workflow-automation/workflows/import */ +export async function importWorkflowFromFile( + request: ApiRequestFunction, + options: ImportWorkflowOptions, +): Promise { + if (!options.envelope && !options.fileId) { + throw new Error('importWorkflowFromFile: either envelope or fileId is required'); + } + return await request({ + url: `${BASE}/workflows/import`, + method: 'post', + data: options, + }); +} + +/** + * GET /api/workflow-automation/workflows/{workflowId}/export + * + * Returns ``{ fileName, envelope }`` when ``download=false`` and a raw JSON + * download (``Content-Disposition: attachment``) when ``download=true``. + */ +export async function exportWorkflowToFile( + request: ApiRequestFunction, + workflowId: string, + download = false, +): Promise { + return await request({ + url: `${BASE}/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}`; +} + +// ------------------------------------------------------------------------- +// Runs +// ------------------------------------------------------------------------- + +/** + * Fetch runs for a specific workflow. + * GET /api/workflow-automation/runs?workflowId={workflowId} + * + * Replaces both: + * /api/workflows/{instanceId}/workflows/{workflowId}/runs + * /api/system/workflow-runs (with workflowId filter) + */ +export async function fetchWorkflowRuns( + request: ApiRequestFunction, + workflowId: string +): Promise { + const data = await request({ + url: `${BASE}/runs`, + method: 'get', + params: { workflowId }, + }); + return data?.runs ?? []; +} + +/** + * Fetch completed runs across all workflows. + * GET /api/workflow-automation/runs?status=completed + */ +export async function fetchCompletedRuns( + request: ApiRequestFunction, + limit = 20 +): Promise { + const data = await request({ + url: `${BASE}/runs`, + method: 'get', + params: { status: 'completed', limit }, + }); + return data?.runs ?? []; +} + +/** + * Fetch all runs (system-level / cross-workflow). + * GET /api/workflow-automation/runs + * + * Replaces /api/system/workflow-runs and /api/automations/runs. + */ +export async function fetchRuns( + request: ApiRequestFunction, + params?: { + status?: string; + workflowId?: string; + limit?: number; + offset?: number; + } +): Promise<{ runs: Automation2Run[]; total?: number }> { + const data = await request({ + url: `${BASE}/runs`, + method: 'get', + params, + }); + return { runs: data?.runs ?? [], total: data?.total }; +} + +/** GET /api/workflow-automation/runs/{runId}/steps */ +export async function fetchRunSteps( + request: ApiRequestFunction, + runId: string +): Promise { + const data = await request({ + url: `${BASE}/runs/${runId}/steps`, + method: 'get', + }); + return data?.steps ?? []; +} + +/** + * Returns the SSE stream URL for a running workflow. + * GET /api/workflow-automation/runs/{runId}/stream + */ +export async function fetchRunStream( + request: ApiRequestFunction, + runId: string +): Promise { + return await request({ + url: `${BASE}/runs/${runId}/stream`, + method: 'get', + }); +} + +/** POST /api/workflow-automation/runs/{runId}/stop */ +export async function stopRun( + request: ApiRequestFunction, + runId: string +): Promise<{ success: boolean }> { + const data = await request({ + url: `${BASE}/runs/${runId}/stop`, + method: 'post', + }); + return { success: Boolean(data?.success) }; +} + +/** GET /api/workflow-automation/runs/{runId}/detail */ +export async function fetchRunDetail( + request: ApiRequestFunction, + runId: string, +): Promise { + const resp = await request({ url: `${BASE}/runs/${runId}/detail`, method: 'get' }); + return resp as WorkspaceRunDetail; +} + +// ------------------------------------------------------------------------- +// Workspace runs (user-facing, paginated) +// ------------------------------------------------------------------------- + +/** + * Paginated workspace runs (replaces /api/automations/runs). + * GET /api/workflow-automation/runs + */ +export async function fetchWorkspaceRuns( + request: ApiRequestFunction, + params: { + scope?: 'mine' | 'mandate'; + status?: string; + targetInstanceId?: string; + workflowId?: string; + limit?: number; + offset?: number; + } = {}, +): Promise<{ runs: WorkspaceRun[]; total: number }> { + const query = new URLSearchParams(); + if (params.scope) query.set('scope', params.scope); + if (params.status) query.set('status', params.status); + if (params.targetInstanceId) query.set('targetInstanceId', params.targetInstanceId); + if (params.workflowId) query.set('workflowId', params.workflowId); + if (params.limit) query.set('limit', String(params.limit)); + if (params.offset) query.set('offset', String(params.offset)); + const qs = query.toString(); + const url = `${BASE}/runs${qs ? `?${qs}` : ''}`; + const resp = await request({ url, method: 'get' }); + return resp as { runs: WorkspaceRun[]; total: number }; +} + +// ------------------------------------------------------------------------- +// Tasks +// ------------------------------------------------------------------------- + +export async function fetchTasks( + request: ApiRequestFunction, + params?: { workflowId?: string; status?: string } +): Promise { + const data = await request({ + url: `${BASE}/tasks`, + method: 'get', + params, + }); + return data?.tasks ?? []; +} + +export async function completeTask( + request: ApiRequestFunction, + taskId: string, + result: Record +): Promise { + return await request({ + url: `${BASE}/tasks/${taskId}/complete`, + method: 'post', + data: { result }, + }); +} + +/** Cancel a pending human task and stop its workflow run. */ +export async function cancelPendingTaskStopRun( + request: ApiRequestFunction, + taskId: string +): Promise<{ success: boolean; runId?: string | null; taskId: string }> { + const data = await request({ + url: `${BASE}/tasks/${taskId}/cancel`, + method: 'post', + }); + return { + success: Boolean(data?.success), + runId: data?.runId, + taskId: data?.taskId ?? taskId, + }; +} + +// ------------------------------------------------------------------------- +// Versions (AutoVersion Lifecycle) +// ------------------------------------------------------------------------- + +export async function fetchVersions( + request: ApiRequestFunction, + workflowId: string +): Promise { + const data = await request({ + url: `${BASE}/workflows/${workflowId}/versions`, + method: 'get', + }); + return data?.versions ?? []; +} + +export async function createDraftVersion( + request: ApiRequestFunction, + workflowId: string +): Promise { + return await request({ + url: `${BASE}/workflows/${workflowId}/versions/draft`, + method: 'post', + }); +} + +export async function publishVersion( + request: ApiRequestFunction, + versionId: string +): Promise { + return await request({ + url: `${BASE}/versions/${versionId}/publish`, + method: 'post', + }); +} + +export async function unpublishVersion( + request: ApiRequestFunction, + versionId: string +): Promise { + return await request({ + url: `${BASE}/versions/${versionId}/unpublish`, + method: 'post', + }); +} + +export async function archiveVersion( + request: ApiRequestFunction, + versionId: string +): Promise { + return await request({ + url: `${BASE}/versions/${versionId}/archive`, + method: 'post', + }); +} + +// ------------------------------------------------------------------------- +// Templates +// ------------------------------------------------------------------------- + +export async function fetchTemplates( + request: ApiRequestFunction, + scope?: AutoTemplateScope, + pagination?: any +): Promise { + const queryParams: Record = {}; + if (scope) queryParams.scope = scope; + if (pagination) queryParams.pagination = JSON.stringify(pagination); + const data = await request({ + url: `${BASE}/templates`, + method: 'get', + params: Object.keys(queryParams).length > 0 ? queryParams : undefined, + }); + if (data?.items && data?.pagination) return data; + return data?.templates ?? []; +} + +export async function createTemplateFromWorkflow( + request: ApiRequestFunction, + workflowId: string, + scope: AutoTemplateScope = 'user' +): Promise { + return await request({ + url: `${BASE}/templates/from-workflow`, + method: 'post', + data: { workflowId, scope }, + }); +} + +export async function copyTemplate( + request: ApiRequestFunction, + templateId: string +): Promise { + return await request({ + url: `${BASE}/templates/${templateId}/copy`, + method: 'post', + }); +} + +export async function shareTemplate( + request: ApiRequestFunction, + templateId: string, + scope: AutoTemplateScope +): Promise { + return await request({ + url: `${BASE}/templates/${templateId}/share`, + method: 'post', + data: { scope }, + }); +} + +// ------------------------------------------------------------------------- +// Connections and Browse (for Email/SharePoint node config) +// ------------------------------------------------------------------------- + +/** Encode connection id/reference for URL path segments (may contain spaces/colons). */ +function _encodedConnectionId(connectionId: string): string { + return encodeURIComponent(connectionId); +} + +export async function fetchConnections( + request: ApiRequestFunction, +): Promise { + const data = await request({ + url: `${BASE}/connections`, + method: 'get', + }); + return data?.connections ?? []; +} + +export async function fetchConnectionServices( + request: ApiRequestFunction, + connectionId: string +): Promise { + const data = await request({ + url: `${BASE}/connections/${_encodedConnectionId(connectionId)}/services`, + method: 'get', + }); + return data?.services ?? []; +} + +export async function fetchBrowse( + request: ApiRequestFunction, + connectionId: string, + service: string, + path = '/' +): Promise<{ items: BrowseEntry[]; path: string; service: string }> { + const data = await request({ + url: `${BASE}/connections/${_encodedConnectionId(connectionId)}/browse`, + method: 'get', + params: { service, path }, + }); + return { items: data?.items ?? [], path: data?.path ?? path, service: data?.service ?? service }; +} + +// ------------------------------------------------------------------------- +// Monitoring / Metrics +// ------------------------------------------------------------------------- + +/** + * GET /api/workflow-automation/metrics + * + * Replaces both /api/workflows/{instanceId}/metrics + * and /api/system/workflow-runs/metrics. + */ +export async function fetchMetrics( + request: ApiRequestFunction, +): Promise { + return await request({ + url: `${BASE}/metrics`, + method: 'get', + }); +} diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index b9923ce..1a3900e 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -23,6 +23,7 @@ import { createTemplateFromWorkflow, copyTemplate, importWorkflowFromFile, + WORKFLOW_FILE_EXTENSION, type NodeType, type NodeTypeCategory, type Automation2Graph, @@ -153,7 +154,7 @@ export const Automation2FlowEditor: React.FC = ({ in instanceId, mandateId: mandateId || '', featureInstanceId: instanceId, - surface: 'graphEditor', + surface: 'workflowAutomation', }), [instanceId, mandateId]); const [versions, setVersions] = useState([]); const [currentVersionId, setCurrentVersionId] = useState(null); @@ -354,7 +355,7 @@ export const Automation2FlowEditor: React.FC = ({ in setExecuteResult(null); try { const ep = currentWorkflowId ? invocations[0]?.id : undefined; - const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined, { + const result = await executeGraph(request, graph, currentWorkflowId ?? undefined, { ...(ep ? { entryPointId: ep } : {}), }); setExecuteResult(result); @@ -403,7 +404,7 @@ export const Automation2FlowEditor: React.FC = ({ in setSaving(true); try { if (currentWorkflowId) { - const updated = await updateWorkflow(request, instanceId, currentWorkflowId, { + const updated = await updateWorkflow(request, currentWorkflowId, { graph, invocations, targetFeatureInstanceId, @@ -420,11 +421,12 @@ export const Automation2FlowEditor: React.FC = ({ in setSaving(false); return; } - const created = await createWorkflow(request, instanceId, { + const created = await createWorkflow(request, { label: label.trim() || t('Neuer Workflow'), graph, invocations, targetFeatureInstanceId, + mandateId, }); setCurrentWorkflowId(created.id); setInvocations(created.invocations ?? []); @@ -436,12 +438,12 @@ export const Automation2FlowEditor: React.FC = ({ in } finally { setSaving(false); } - }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId, hasCanvasStartNode]); + }, [request, mandateId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId, hasCanvasStartNode]); const handleLoad = useCallback( async (workflowId: string) => { try { - const wf = await fetchWorkflow(request, instanceId, workflowId); + const wf = await fetchWorkflow(request, workflowId); if (wf.graph) { handleFromApiGraph(wf.graph, wf.invocations); } else { @@ -463,7 +465,7 @@ export const Automation2FlowEditor: React.FC = ({ in setExecuteResult(null); applyGraphWithSync({ nodes: [], connections: [] }, []); try { - const result = await fetchWorkflows(request, instanceId); + const result = await fetchWorkflows(request); setWorkflows(Array.isArray(result) ? result : result.items); } catch (refreshErr) { console.error(`${LOG} workflows refresh failed`, refreshErr); @@ -476,7 +478,7 @@ export const Automation2FlowEditor: React.FC = ({ in }); } }, - [request, instanceId, handleFromApiGraph, applyGraphWithSync, t] + [request, handleFromApiGraph, applyGraphWithSync, t] ); const handleWorkflowSelect = useCallback( @@ -544,11 +546,10 @@ export const Automation2FlowEditor: React.FC = ({ in ); const loadNodeTypes = useCallback(async () => { - if (!instanceId) return; setLoading(true); setError(null); try { - const data = await fetchNodeTypes(request, instanceId, language); + const data = await fetchNodeTypes(request, language); setNodeTypes(data.nodeTypes); setCategories(data.categories); if (data.portTypeCatalog) { @@ -565,17 +566,16 @@ export const Automation2FlowEditor: React.FC = ({ in } finally { setLoading(false); } - }, [instanceId, language, request]); + }, [language, request]); const loadWorkflows = useCallback(async () => { - if (!instanceId) return; try { - const result = await fetchWorkflows(request, instanceId); + const result = await fetchWorkflows(request); setWorkflows(Array.isArray(result) ? result : result.items); } catch (e) { console.error(`${LOG} loadWorkflows failed`, e); } - }, [instanceId, request]); + }, [request]); useEffect(() => { loadNodeTypes(); @@ -665,17 +665,17 @@ export const Automation2FlowEditor: React.FC = ({ in ); const loadVersions = useCallback(async () => { - if (!instanceId || !currentWorkflowId) { + if (!currentWorkflowId) { setVersions([]); return; } try { - const v = await fetchVersions(request, instanceId, currentWorkflowId); + const v = await fetchVersions(request, currentWorkflowId); setVersions(v); } catch (e) { console.error(`${LOG} loadVersions failed`, e); } - }, [instanceId, currentWorkflowId, request]); + }, [currentWorkflowId, request]); useEffect(() => { loadVersions(); @@ -696,10 +696,9 @@ export const Automation2FlowEditor: React.FC = ({ in const handlePublishVersion = useCallback( async (versionId: string) => { - if (!instanceId) return; setVersionLoading(true); try { - await publishVersion(request, instanceId, versionId); + await publishVersion(request, versionId); await loadVersions(); } catch (e: unknown) { setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); @@ -707,15 +706,14 @@ export const Automation2FlowEditor: React.FC = ({ in setVersionLoading(false); } }, - [request, instanceId, loadVersions] + [request, loadVersions] ); const handleUnpublishVersion = useCallback( async (versionId: string) => { - if (!instanceId) return; setVersionLoading(true); try { - await unpublishVersion(request, instanceId, versionId); + await unpublishVersion(request, versionId); await loadVersions(); } catch (e: unknown) { setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); @@ -723,15 +721,14 @@ export const Automation2FlowEditor: React.FC = ({ in setVersionLoading(false); } }, - [request, instanceId, loadVersions] + [request, loadVersions] ); const handleArchiveVersion = useCallback( async (versionId: string) => { - if (!instanceId) return; setVersionLoading(true); try { - await archiveVersion(request, instanceId, versionId); + await archiveVersion(request, versionId); await loadVersions(); } catch (e: unknown) { setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); @@ -739,14 +736,14 @@ export const Automation2FlowEditor: React.FC = ({ in setVersionLoading(false); } }, - [request, instanceId, loadVersions] + [request, loadVersions] ); const handleCreateDraft = useCallback(async () => { - if (!instanceId || !currentWorkflowId) return; + if (!currentWorkflowId) return; setVersionLoading(true); try { - const draft = await createDraftVersion(request, instanceId, currentWorkflowId); + const draft = await createDraftVersion(request, currentWorkflowId); await loadVersions(); setCurrentVersionId(draft.id); } catch (e: unknown) { @@ -754,16 +751,16 @@ export const Automation2FlowEditor: React.FC = ({ in } finally { setVersionLoading(false); } - }, [request, instanceId, currentWorkflowId, loadVersions]); + }, [request, currentWorkflowId, loadVersions]); // Template: save current workflow as template const [templateSaving, setTemplateSaving] = useState(false); const handleSaveAsTemplate = useCallback( async (scope: AutoTemplateScope) => { - if (!instanceId || !currentWorkflowId) return; + if (!currentWorkflowId) return; setTemplateSaving(true); try { - await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope); + await createTemplateFromWorkflow(request, currentWorkflowId, scope); setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse); } catch (e: unknown) { setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); @@ -771,16 +768,15 @@ export const Automation2FlowEditor: React.FC = ({ in setTemplateSaving(false); } }, - [request, instanceId, currentWorkflowId] + [request, currentWorkflowId] ); // Template: new workflow from template const [templatePickerOpen, setTemplatePickerOpen] = useState(false); const handleNewFromTemplate = useCallback( async (templateId: string) => { - if (!instanceId) return; try { - const wf = await copyTemplate(request, instanceId, templateId); + const wf = await copyTemplate(request, templateId); setWorkflows((prev) => [...prev, wf]); setCurrentWorkflowId(wf.id); if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations); @@ -789,7 +785,7 @@ export const Automation2FlowEditor: React.FC = ({ in setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) }); } }, - [request, instanceId, handleFromApiGraph] + [request, handleFromApiGraph] ); @@ -947,12 +943,20 @@ export const Automation2FlowEditor: React.FC = ({ in activeTab={udbTab as UdbTab} onTabChange={(tab) => setUdbTab(tab as LeftTab)} hideTabs={['chats']} - onFileSelect={onFileSelect} - onSourcesChanged={onSourcesChanged} - onWorkflowImportedFromFile={async (workflowId) => { - await loadWorkflows(); - handleWorkflowSelect(workflowId); + onFileSelect={async (fileId, fileName) => { + if (fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) { + try { + const result = await importWorkflowFromFile(request, { fileId }); + await loadWorkflows(); + if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id); + } catch (e) { + console.error('[workflowAutomation] workflow file import failed', e); + } + return; + } + onFileSelect?.(fileId, fileName); }} + onSourcesChanged={onSourcesChanged} /> )} @@ -1024,12 +1028,12 @@ export const Automation2FlowEditor: React.FC = ({ in stickyNotes={canvasStickyNotes} onStickyNotesChange={setCanvasStickyNotes} onExternalDrop={async (mime, payload) => { - if (mime !== 'application/json+workflow' || !instanceId) return false; + if (mime !== 'application/json+workflow') return false; const p = payload as { files?: Array<{ id: string }> } | undefined; const fileId = p?.files?.[0]?.id; if (!fileId) return false; try { - const result = await importWorkflowFromFile(request, instanceId, { fileId }); + const result = await importWorkflowFromFile(request, { fileId }); await loadWorkflows(); if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id); return true; diff --git a/src/components/FlowEditor/editor/EditorChatPanel.tsx b/src/components/FlowEditor/editor/EditorChatPanel.tsx index 3ce248e..0eb8a25 100644 --- a/src/components/FlowEditor/editor/EditorChatPanel.tsx +++ b/src/components/FlowEditor/editor/EditorChatPanel.tsx @@ -1,7 +1,7 @@ /** * EditorChatPanel * - * AI Chat sidebar for the GraphicalEditor. + * AI Chat sidebar for the WorkflowAutomation editor. * Streams responses via SSE (same pattern as Workspace chat). * File & data-source attachment UX mirrors WorkspaceInput: * - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB @@ -87,7 +87,7 @@ export const EditorChatPanel: React.FC = ({ instanceId, // Load persisted chat history from the backend whenever the workflow changes. // The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is - // returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`. + // returned by `GET /api/workflow-automation/{workflowId}/chat/messages`. // For an unsaved workflow (workflowId == null) we just clear the panel. useEffect(() => { if (!workflowId) { @@ -99,7 +99,7 @@ export const EditorChatPanel: React.FC = ({ instanceId, setHistoryLoading(true); try { const res = await api.get( - `/api/workflows/${instanceId}/${workflowId}/chat/messages`, + `/api/workflow-automation/${workflowId}/chat/messages`, ); if (cancelled) return; const persisted = (res.data?.messages || []).map((m): ChatMessage => ({ @@ -166,7 +166,7 @@ export const EditorChatPanel: React.FC = ({ instanceId, const baseURL = api.defaults.baseURL || ''; const cleanup = startSseStream({ - url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`, + url: `${baseURL}/api/workflow-automation/${workflowId}/chat/stream`, body, handlers: { onChunk: (event) => { @@ -227,7 +227,7 @@ export const EditorChatPanel: React.FC = ({ instanceId, : m)); } try { - await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`); + await api.post(`/api/workflow-automation/${workflowId}/chat/stop`); } catch { } abortRef.current?.(); diff --git a/src/components/FlowEditor/editor/EditorWorkflowChatList.tsx b/src/components/FlowEditor/editor/EditorWorkflowChatList.tsx index 7973c98..f050d79 100644 --- a/src/components/FlowEditor/editor/EditorWorkflowChatList.tsx +++ b/src/components/FlowEditor/editor/EditorWorkflowChatList.tsx @@ -1,11 +1,11 @@ /** * EditorWorkflowChatList * - * UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated - * as one editor chat session. Lists workflows already loaded by the parent - * editor (no extra fetch), supports search and "+ Neu" to start a fresh + * UDB "Chats" tab content for the WorkflowAutomation editor: each AutoWorkflow + * is treated as one editor chat session. Lists workflows already loaded by the + * parent editor (no extra fetch), supports search and "+ Neu" to start a fresh * workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses - * GraphicalEditor data instead of the workspace endpoint. + * WorkflowAutomation data instead of the workspace endpoint. */ import React, { useMemo, useState } from 'react'; import type { Automation2Workflow } from '../../../api/workflowApi'; diff --git a/src/components/FlowEditor/editor/RunTracingPanel.tsx b/src/components/FlowEditor/editor/RunTracingPanel.tsx index 238ae63..c3eecfa 100644 --- a/src/components/FlowEditor/editor/RunTracingPanel.tsx +++ b/src/components/FlowEditor/editor/RunTracingPanel.tsx @@ -98,7 +98,7 @@ export const RunTracingPanel: React.FC = ({ setLoading(true); try { const data = await request({ - url: `/api/workflows/${instanceId}/runs/${runId}/steps`, + url: `/api/workflow-automation/runs/${runId}/steps`, method: 'get', }); setSteps(data?.steps || []); @@ -115,7 +115,7 @@ export const RunTracingPanel: React.FC = ({ loadSteps(); const baseUrl = api.defaults.baseURL || ''; - const url = `${baseUrl}/api/workflows/${instanceId}/runs/${runId}/stream`; + const url = `${baseUrl}/api/workflow-automation/runs/${runId}/stream`; const es = new EventSource(url, { withCredentials: true }); eventSourceRef.current = es; diff --git a/src/components/FlowEditor/editor/TemplatePicker.tsx b/src/components/FlowEditor/editor/TemplatePicker.tsx index 6cf16ef..d25af24 100644 --- a/src/components/FlowEditor/editor/TemplatePicker.tsx +++ b/src/components/FlowEditor/editor/TemplatePicker.tsx @@ -50,7 +50,7 @@ export const TemplatePicker: React.FC = ({ setLoading(true); try { const scope = activeScope === 'all' ? undefined : activeScope; - const result = await fetchTemplates(request, instanceId, scope); + const result = await fetchTemplates(request, scope); setTemplates(Array.isArray(result) ? result : result.items); } catch { setTemplates([]); diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/CaseListEditor.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/CaseListEditor.tsx index a6722ba..267ff88 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/CaseListEditor.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/CaseListEditor.tsx @@ -157,7 +157,7 @@ export const CaseListEditor: React.FC = ({ if (dataFlow?.instanceId && dataFlow.request) { setLoading(true); - fetchConditionMeta(dataFlow.request, dataFlow.instanceId, { + fetchConditionMeta(dataFlow.request, { graph: toApiGraph(dataFlow.nodes, dataFlow.connections), nodeId: dataFlow.currentNodeId, ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path }, diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/ConditionEditor.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/ConditionEditor.tsx index ff23dfc..b50983d 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/ConditionEditor.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/ConditionEditor.tsx @@ -83,7 +83,7 @@ export const ConditionEditor: React.FC = ({ if (dataFlow?.instanceId && dataFlow.request) { setLoading(true); - fetchConditionMeta(dataFlow.request, dataFlow.instanceId, { + fetchConditionMeta(dataFlow.request, { graph: toApiGraph(dataFlow.nodes, dataFlow.connections), nodeId: dataFlow.currentNodeId, ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path }, diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/FeatureInstancePicker.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/FeatureInstancePicker.tsx index 925e310..572ac3d 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/FeatureInstancePicker.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/FeatureInstancePicker.tsx @@ -3,7 +3,7 @@ * * Modeled on ConnectionPicker. Loads mandate-scoped FeatureInstances filtered * by `frontendOptions.featureCode` (e.g. "trustee", "redmine") via - * GET /api/workflows/{instanceId}/options/feature.instance?featureCode= + * GET /api/workflow-automation/options/feature.instance?featureCode= * * Behavior matches the rest of the editor: * - 0 results -> hint to create a feature instance for this mandate @@ -42,7 +42,7 @@ export const FeatureInstancePicker: React.FC = ({ setLoading(true); setLoadError(null); request({ - url: `/api/workflows/${instanceId}/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`, + url: `/api/workflow-automation/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`, method: 'get', }) .then((res: unknown) => { diff --git a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx index a2d9193..fa5f37e 100644 --- a/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx +++ b/src/components/FlowEditor/nodes/frontendTypeRenderers/index.tsx @@ -310,7 +310,7 @@ const ConnectionPicker: React.FC = ({ param, value, onChange if (!instanceId || !request) return; const qs = authority ? `?authority=${encodeURIComponent(authority)}` : ''; setLoadError(null); - request({ url: `/api/workflows/${instanceId}/options/user.connection${qs}`, method: 'get' }) + request({ url: `/api/workflow-automation/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 }))); @@ -328,7 +328,7 @@ const ConnectionPicker: React.FC = ({ param, value, onChange return; } const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections); - postUpstreamPaths(request, instanceId, graph, dataFlow.currentNodeId) + postUpstreamPaths(request, graph, dataFlow.currentNodeId) .then(({ paths }) => { const opts = paths .filter( diff --git a/src/components/FlowEditor/nodes/shared/DataPicker.tsx b/src/components/FlowEditor/nodes/shared/DataPicker.tsx index 9cc9fb9..d1a289b 100644 --- a/src/components/FlowEditor/nodes/shared/DataPicker.tsx +++ b/src/components/FlowEditor/nodes/shared/DataPicker.tsx @@ -303,7 +303,7 @@ export const DataPicker: React.FC = ({ open, if (scopeFetchKey.current === key) return; // already fetched for this state scopeFetchKey.current = key; const nodeShapes = (ctx.nodes ?? []).map((n) => ({ id: n.id, type: n.type })); - fetchGraphDataSources(ctx.request, ctx.instanceId, ctx.currentNodeId, nodeShapes, connections) + fetchGraphDataSources(ctx.request, ctx.currentNodeId, nodeShapes, connections) .then(setScopeData) .catch(() => setScopeData(null)); }, [open, ctx?.instanceId, ctx?.request, ctx?.currentNodeId, connections, nodesRaw]); diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx index 87d7675..6086199 100644 --- a/src/components/UnifiedDataBar/FilesTab.tsx +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -1,12 +1,6 @@ import React, { useCallback, useRef, useMemo, useState, useEffect } from 'react'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; -import { useApiRequest } from '../../hooks/useApi'; -import { - importWorkflowFromFile, - WORKFLOW_FILE_EXTENSION, -} from '../../api/workflowApi'; -import { useToast } from '../../contexts/ToastContext'; import { FormGeneratorTree } from '../FormGenerator/FormGeneratorTree'; import { createFolderFileProvider } from '../FormGenerator/FormGeneratorTree/providers/FolderFileProvider'; import type { TreeNode } from '../FormGenerator/FormGeneratorTree'; @@ -17,15 +11,10 @@ interface FilesTabProps { context: UdbContext; onFileSelect?: (fileId: string, fileName?: string) => void; onSendToChat?: (items: Array<{ id: string; type: 'file' | 'group'; name: string }>) => void; - /** Wird aufgerufen, wenn ein ``.workflow.json``-File via Custom-Action in - * den Graph-Editor importiert wurde. */ - onWorkflowImported?: (workflowId: string) => void; } -const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat, onWorkflowImported }) => { +const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat }) => { const { t } = useLanguage(); - const { request } = useApiRequest(); - const { showSuccess, showError } = useToast(); const [isDragOver, setIsDragOver] = useState(false); const [uploading, setUploading] = useState(false); const [uploadProgressPercent, setUploadProgressPercent] = useState(0); @@ -126,33 +115,6 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat } }, [_uploadFiles]); - /* Workflow import is only available when embedded in the graph editor */ - const _handleWorkflowImport = useCallback(async (fileId: string, fileName: string) => { - if (context.surface !== 'graphEditor' || !context.instanceId) return; - if (!fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) return; - try { - const result = await importWorkflowFromFile(request, context.instanceId, { fileId }); - const warnings = result?.warnings ?? []; - const wfId = result?.workflow?.id; - if (warnings.length > 0) { - showSuccess(t('Workflow importiert ({n} Warnungen).', { n: String(warnings.length) })); - } else { - showSuccess(t('Workflow importiert (deaktiviert).')); - } - if (wfId && onWorkflowImported) onWorkflowImported(wfId); - } catch (e: unknown) { - const msg = e instanceof Error ? e.message : String(e); - showError(t('Import fehlgeschlagen: {msg}', { msg })); - } - }, [context.surface, context.instanceId, request, showSuccess, showError, t, onWorkflowImported]); - - const _handleNodeClickWithImport = useCallback((node: TreeNode) => { - _handleNodeClick(node); - if (node.type === 'file') { - _handleWorkflowImport(node.id, node.name); - } - }, [_handleNodeClick, _handleWorkflowImport]); - const _handleSendToChat = useCallback((node: TreeNode) => { onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]); }, [onSendToChat]); @@ -243,7 +205,7 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat title={t('Eigene')} compact={true} showFilter={true} - onNodeClick={_handleNodeClickWithImport} + onNodeClick={_handleNodeClick} onSendToChat={_handleSendToChat} /> = ({ context, onFileSelect, onSendToChat collapsible={true} defaultCollapsed={true} emptyMessage={t('Keine geteilten Dateien')} - onNodeClick={_handleNodeClickWithImport} + onNodeClick={_handleNodeClick} onSendToChat={_handleSendToChat} /> diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx index 2366c2c..ca6ebc2 100644 --- a/src/components/UnifiedDataBar/UnifiedDataBar.tsx +++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx @@ -8,14 +8,10 @@ import styles from './UnifiedDataBar.module.css'; export type UdbTab = 'chats' | 'files' | 'sources'; /** Aufruf-Surface, in der die UDB gerade lebt. Wird an Custom-Actions - * (z. B. `workflow.openInEditor`) weitergereicht, damit sie sich - * pro Surface registrieren können. */ -export type UdbSurface = - | 'workspace' - | 'graphEditor' - | 'trustee' - | 'standalone' - | 'sharepoint'; + * weitergereicht, damit sie sich pro Surface registrieren koennen. + * Bekannte Werte: 'workspace', 'workflowAutomation', 'trustee', + * 'standalone', 'sharepoint' — beliebig erweiterbar durch Consumer. */ +export type UdbSurface = string; export interface UdbContext { instanceId: string; @@ -56,9 +52,6 @@ interface UnifiedDataBarProps { onSendToChat_Files?: (items: AddToChat_FileItem[]) => void; onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void; onAttachDataSource?: (dsId: string) => void; - /** Wird aufgerufen, sobald aus der UDB-FilesTab ein Workflow-File in den - * Graph-Editor importiert wurde (Action `workflow.openInEditor`). */ - onWorkflowImportedFromFile?: (workflowId: string) => void; className?: string; } @@ -87,7 +80,6 @@ const UnifiedDataBar: React.FC = ({ onSendToChat_Files, onSendToChat_FeatureSource, onAttachDataSource, - onWorkflowImportedFromFile, className, }) => { const { t } = useLanguage(); @@ -132,7 +124,6 @@ const UnifiedDataBar: React.FC = ({ context={context} onFileSelect={onFileSelect} onSendToChat={onSendToChat_Files} - onWorkflowImported={onWorkflowImportedFromFile} /> )} {currentTab === 'sources' && !hideTabs?.includes('sources') && ( diff --git a/src/components/workflowAutomation/FlowEditor/index.ts b/src/components/workflowAutomation/FlowEditor/index.ts new file mode 100644 index 0000000..7e02125 --- /dev/null +++ b/src/components/workflowAutomation/FlowEditor/index.ts @@ -0,0 +1,8 @@ +/** + * FlowEditor re-export shim. + * + * Allows gradual migration of imports to the workflowAutomation folder + * without breaking anything. All exports proxy through to ../../FlowEditor. + */ + +export * from '../../FlowEditor'; diff --git a/src/config/keepAliveRoutes.tsx b/src/config/keepAliveRoutes.tsx index 80a57c3..35f94f8 100644 --- a/src/config/keepAliveRoutes.tsx +++ b/src/config/keepAliveRoutes.tsx @@ -3,8 +3,14 @@ import { AdminDatabaseHealthPage } from '../pages/admin/AdminDatabaseHealthPage' import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage'; import { CommcoachSessionView } from '../pages/views/commcoach'; import { WorkspacePage } from '../pages/views/workspace/WorkspacePage'; +import { WorkflowAutomationPage } from '../pages/workflowAutomation/WorkflowAutomationHubPage'; export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [ + { + id: 'workflow-automation-editor', + pathRegex: /\/workflow-automation(?:\?.*tab=editor|$)/, + render: () => , + }, { id: 'workspace-dashboard', pathRegex: /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/, diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 866d345..baa5d68 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -22,7 +22,7 @@ import { FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase, FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock, FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList, - FaFileContract, FaRobot, FaGlobe, FaClipboardCheck, + FaFileContract, FaGlobe, FaClipboardCheck, FaSitemap, FaCopy, FaTasks, } from 'react-icons/fa'; @@ -53,7 +53,6 @@ export const PAGE_ICONS: Record = { // System pages - Usage 'page.system.billingAdmin': , 'page.system.statistics': , - 'page.system.automations': , 'page.system.ragInventory': , // System pages - Workflow Automation diff --git a/src/hooks/useIntegrationsOverview.ts b/src/hooks/useIntegrationsOverview.ts index 0e07a22..0565d2e 100644 --- a/src/hooks/useIntegrationsOverview.ts +++ b/src/hooks/useIntegrationsOverview.ts @@ -100,11 +100,11 @@ function _dotColorForIndex(index: number): string { return palette[index % palette.length]; } -function _collectGraphicalEditorInstanceIds(mandates: NavigationMandate[]): string[] { +function _collectWorkflowAutomationInstanceIds(mandates: NavigationMandate[]): string[] { const ids: string[] = []; for (const mandate of mandates) { for (const feature of mandate.features) { - if (feature.uiComponent === 'feature.graphicalEditor') { + if (feature.uiComponent === 'feature.workflowAutomation') { for (const inst of feature.instances) { if (inst.id && !ids.includes(inst.id)) { ids.push(inst.id); @@ -271,13 +271,13 @@ export function useIntegrationsOverview(): UseIntegrationsOverviewResult { setError((prev) => (prev ? `${prev} | ${msg}` : msg)); } - const geIds = _collectGraphicalEditorInstanceIds(mandatesForWorkflows); + const waIds = _collectWorkflowAutomationInstanceIds(mandatesForWorkflows); const wfLabels: string[] = []; const seenWf = new Set(); - for (const instanceId of geIds.slice(0, 4)) { + for (const instanceId of waIds.slice(0, 4)) { try { - const wfRes = await api.get(`/api/workflows/${instanceId}/workflows`, { - params: { active: 'true' }, + const wfRes = await api.get(`/api/workflow-automation/workflows`, { + params: { active: 'true', instanceId }, }); const wfData = wfRes.data; const list = Array.isArray(wfData) diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts index 4723e70..62a41b2 100644 --- a/src/hooks/useWorkflows.ts +++ b/src/hooks/useWorkflows.ts @@ -7,25 +7,22 @@ import { fetchWorkflow as fetchWorkflowFromApi, deleteWorkflow as deleteWorkflowFromApi, updateWorkflow as updateWorkflowFromApi, -} from '../api/workflowApi'; +} from '../api/workflowAutomationApi'; import { useWorkflowSelection } from '../contexts/WorkflowSelectionContext'; import { usePermissions, type UserPermissions } from './usePermissions'; export type StartWorkflowRequest = Record; -function _workflowsInstanceIdFromBaseUrl(apiBaseUrl: string | undefined): string | null { - if (!apiBaseUrl) return null; - const m = apiBaseUrl.match(/^\/api\/workflows\/([^/]+)$/); - return m ? m[1] : null; +function _isValidApiBaseUrl(apiBaseUrl: string | undefined): boolean { + return apiBaseUrl === '/api/workflow-automation'; } async function _deleteWorkflowsSequential( request: ApiRequestFunction, - instanceId: string, workflowIds: string[], ) { for (const id of workflowIds) { - await deleteWorkflowFromApi(request, instanceId, id); + await deleteWorkflowFromApi(request, id); } } @@ -36,7 +33,7 @@ async function startWorkflowApi( options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' }, ) { return await request({ - url: `/api/workflows/${instanceId}/execute`, + url: `/api/workflow-automation/execute`, method: 'post', data: { workflowId: options?.workflowId ?? (workflowData as { workflowId?: string }).workflowId, @@ -105,10 +102,10 @@ export interface PaginationParams { search?: string; } -/** Get apiBaseUrl from instanceId and featureCode for feature-scoped workflow APIs */ +/** Get apiBaseUrl for workflow APIs (mandate-scoped) */ export function getWorkflowApiBaseUrl(instanceId: string | undefined, featureCode: string | undefined): string | undefined { - if (!instanceId || !featureCode) return undefined; - if (featureCode === 'graphicalEditor') return `/api/workflows/${instanceId}`; + if (!featureCode) return undefined; + if (featureCode === 'workflowAutomation') return `/api/workflow-automation`; return undefined; } @@ -162,13 +159,8 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?: const fetchWorkflowsData = useCallback(async (params?: PaginationParams) => { try { - if (!apiBaseUrl) { - console.error('useUserWorkflows: apiBaseUrl is required (missing instanceId/featureCode)'); - return; - } - const instanceId = _workflowsInstanceIdFromBaseUrl(apiBaseUrl); - if (!instanceId) { - console.error('useUserWorkflows: could not parse instanceId from apiBaseUrl'); + if (!apiBaseUrl || !_isValidApiBaseUrl(apiBaseUrl)) { + console.error('useUserWorkflows: apiBaseUrl is required (missing featureCode)'); return; } let listParams: { pagination?: Record } | undefined = undefined; @@ -183,7 +175,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?: listParams = { pagination: paginationObj }; } } - const data: unknown = await fetchWorkflowsFromApi(request, instanceId, listParams); + const data: unknown = await fetchWorkflowsFromApi(request, listParams ? { pagination: listParams.pagination } : undefined); // Handle paginated response if (data && typeof data === 'object' && data !== null && 'items' in data) { @@ -246,9 +238,8 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?: // Fetch a single workflow by ID const fetchWorkflowById = useCallback(async (workflowId: string): Promise => { try { - const instanceId = _workflowsInstanceIdFromBaseUrl(apiBaseUrl); - if (!instanceId) return null; - const workflow = await fetchWorkflowFromApi(request, instanceId, workflowId); + if (!_isValidApiBaseUrl(apiBaseUrl)) return null; + const workflow = await fetchWorkflowFromApi(request, workflowId); return workflow as unknown as UserWorkflow | null; } catch (error: any) { console.error('Error fetching workflow by ID:', error); @@ -526,8 +517,7 @@ export function useWorkflowOperations(options?: { instanceId?: string; featureCo setDeletingWorkflows, setDeleteError, () => { - if (!instanceId) throw new Error('instanceId required'); - return deleteWorkflowFromApi(request, instanceId, workflowId); + return deleteWorkflowFromApi(request, workflowId); }, { default: 'Failed to delete workflow', @@ -563,8 +553,7 @@ export function useWorkflowOperations(options?: { instanceId?: string; featureCo }); try { - if (!instanceId) throw new Error('instanceId required'); - await _deleteWorkflowsSequential(request, instanceId, workflowIds); + await _deleteWorkflowsSequential(request, workflowIds); // Add a small delay to ensure backend has time to process await new Promise(resolve => setTimeout(resolve, 300)); @@ -638,8 +627,7 @@ export function useWorkflowOperations(options?: { instanceId?: string; featureCo setEditingWorkflows(prev => new Set(prev).add(workflowId)); try { - if (!instanceId) throw new Error('instanceId required'); - const updatedWorkflow = await updateWorkflowFromApi(request, instanceId, workflowId, { + const updatedWorkflow = await updateWorkflowFromApi(request, workflowId, { label: updateData.name, }); diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx deleted file mode 100644 index 1a60022..0000000 --- a/src/pages/AutomationsDashboardPage.tsx +++ /dev/null @@ -1,22 +0,0 @@ -/** - * AutomationsDashboardPage - * - * Legacy wrapper — redirects to /workflow-automation. - * The full automation hub now lives in WorkflowAutomationPage. - */ - -import React from 'react'; -import { Navigate, useSearchParams } from 'react-router-dom'; - -export const AutomationsDashboardPage: React.FC = () => { - const [searchParams] = useSearchParams(); - const tab = searchParams.get('tab'); - const runId = searchParams.get('runId'); - const params = new URLSearchParams(); - if (tab) params.set('tab', tab); - if (runId) params.set('runId', runId); - const qs = params.toString(); - return ; -}; - -export default AutomationsDashboardPage; diff --git a/src/pages/WorkflowAutomationPage.tsx b/src/pages/WorkflowAutomationPage.tsx deleted file mode 100644 index 00a4a0a..0000000 --- a/src/pages/WorkflowAutomationPage.tsx +++ /dev/null @@ -1,1565 +0,0 @@ -/** - * WorkflowAutomationPage - * - * System-level hub for WorkflowAutomation (mandatsweite Sicht). - * Tabs: Workflows · Editor · Vorlagen · Läufe · Details - * - * Replaces the former AutomationsDashboardPage at /automations. - * Uses /api/system/workflow-runs/* endpoints (proven, RBAC-filtered). - * Editor + Templates tabs embed the existing graphicalEditor components. - */ - -import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; -import { useNavigate, useSearchParams } from 'react-router-dom'; -import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload, FaCheck, FaBan, FaPen, FaEye, FaTimes, FaStream, FaStop } from 'react-icons/fa'; -import { FormGeneratorTable, type ColumnConfig } from '../components/FormGenerator'; -import { Tabs } from '../components/UiComponents/Tabs'; -import { useToast } from '../contexts/ToastContext'; -import { usePrompt } from '../hooks/usePrompt'; -import { useApiRequest } from '../hooks/useApi'; -import { formatUnixTimestamp } from '../utils/time'; -import { updateWorkflow, executeGraph, deleteSystemWorkflow, fetchWorkspaceRunDetail } from '../api/workflowApi'; -import { fetchAttributes } from '../api/attributesApi'; -import type { AttributeDefinition } from '../api/attributesApi'; -import { resolveColumnTypes } from '../utils/columnTypeResolver'; -import api from '../api'; -import { useLanguage } from '../providers/language/LanguageContext'; -import { useNavigation, type DynamicBlock } from '../hooks/useNavigation'; -import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage'; -import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage'; -import styles from './admin/Admin.module.css'; - -// --------------------------------------------------------------------------- -// Shared types & helpers -// --------------------------------------------------------------------------- - -interface WorkflowRunMetrics { - totalRuns: number; - runsByStatus: Record; - totalTokens: number; - totalCredits: number; - workflowCount: number; - activeWorkflows: number; -} - -interface WorkflowRun { - id: string; - workflowId: string; - workflowLabel?: string; - mandateId?: string; - mandateLabel?: string; - featureInstanceId?: string; - instanceLabel?: string; - ownerId?: string; - ownerLabel?: string; - status: string; - costTokens?: number; - costCredits?: number; - sysCreatedAt?: number; - sysModifiedAt?: number; -} - -interface SystemWorkflow { - id: string; - mandateId: string; - featureInstanceId: string; - featureCode?: string; - label: string; - active: boolean; - isRunning?: boolean; - activeRunId?: string; - stuckAtNodeLabel?: string; - stuckAtNodeId?: string; - createdAt?: number; - sysCreatedAt?: number; - lastStartedAt?: number; - runCount?: number; - mandateLabel?: string; - instanceLabel?: string; - ownerId?: string; - ownerLabel?: string; - canEdit?: boolean; - canDelete?: boolean; - canExecute?: boolean; - invocations?: Array<{ id: string; enabled: boolean; kind: string }>; - graph?: Record; -} - -const _FEATURES_WITH_EDITOR = new Set(['graphicalEditor', 'workspace']); - -const _ROLE_PRIORITY: Record = { admin: 3, user: 2, viewer: 1 }; - -function _bestEditorInstance( - dynamicBlock: DynamicBlock | null, - mandateId: string, -): { instanceId: string; featureCode: string } | null { - if (!dynamicBlock) return null; - const mandate = dynamicBlock.mandates.find((m) => m.id === mandateId); - if (!mandate) return null; - - let best: { instanceId: string; featureCode: string; score: number } | null = null; - for (const feat of mandate.features) { - for (const inst of feat.instances) { - const fc = inst.featureCode - || feat.uiComponent.replace(/^feature\./, ''); - if (!_FEATURES_WITH_EDITOR.has(fc)) continue; - let score = 0; - if (inst.isAdmin) { - score = 10; - } else { - for (const v of inst.views) { - const key = v.objectKey || ''; - for (const [suffix, prio] of Object.entries(_ROLE_PRIORITY)) { - if (key.endsWith(suffix) && prio > score) score = prio; - } - } - } - if (!best || score > best.score) { - best = { instanceId: inst.id, featureCode: fc, score }; - } - } - } - return best ? { instanceId: best.instanceId, featureCode: best.featureCode } : null; -} - -/** Find the first available editor instance across all mandates. */ -function _findAnyEditorInstance( - dynamicBlock: DynamicBlock | null, -): { instanceId: string; mandateId: string; featureCode: string } | null { - if (!dynamicBlock) return null; - for (const mandate of dynamicBlock.mandates) { - const result = _bestEditorInstance(dynamicBlock, mandate.id); - if (result) return { ...result, mandateId: mandate.id }; - } - return null; -} - -function _formatTs(ts?: number): string { - if (ts == null || ts <= 0) return '—'; - const sec = ts < 1e12 ? ts : ts / 1000; - const { time } = formatUnixTimestamp(sec, undefined, { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - return time; -} - -const _STATUS_COLORS: Record = { - completed: 'var(--success-color, #28a745)', - failed: 'var(--danger-color, #dc3545)', - running: 'var(--primary-color, #007bff)', - paused: 'var(--warning-color, #ffc107)', - stopped: 'var(--warning-color, #ffc107)', - cancelled: 'var(--text-secondary, #666)', -}; - -// --------------------------------------------------------------------------- -// MetricCard -// --------------------------------------------------------------------------- - -interface MetricCardProps { - icon: React.ReactNode; - label: string; - value: string | number; - color?: string; -} - -const MetricCard: React.FC = ({ icon, label, value, color }) => ( -
-
- {icon} -
-
-
{label}
-
{value}
-
-
-); - -// =========================================================================== -// Live Run Tracing Modal (SSE-based, can be opened/closed freely) -// =========================================================================== - -interface _TracingStep { - id: string; - nodeId: string; - nodeType: string; - status: string; - startedAt?: number; - completedAt?: number; - durationMs?: number; - error?: string; - tokensUsed?: number; - inputSnapshot?: Record; - output?: Record; - retryCount?: number; -} - -const _STATUS_ICONS: Record = { - pending: '○', running: '◉', completed: '✓', failed: '✗', stopped: '■', skipped: '—', -}; - -function _formatStepTs(ts: number | string | null | undefined): string { - if (!ts) return ''; - const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts); - if (isNaN(d.getTime())) return ''; - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); -} - -function _truncateJson(obj: unknown, maxLen = 300): string { - if (!obj || (typeof obj === 'object' && Object.keys(obj as object).length === 0)) return ''; - try { - const s = JSON.stringify(obj, null, 2); - return s.length > maxLen ? s.slice(0, maxLen) + '\n...' : s; - } catch { - return String(obj); - } -} - -const _CollapsibleSection: React.FC<{ label: string; content: string }> = ({ label, content }) => { - const [open, setOpen] = useState(false); - if (!content) return null; - return ( -
- - {open && ( -
-          {content}
-        
- )} -
- ); -}; - -interface _RunTracingModalProps { - run: WorkflowRun; - onClose: () => void; -} - -const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) => { - const { t } = useLanguage(); - const [steps, setSteps] = useState<_TracingStep[]>([]); - const [loading, setLoading] = useState(false); - const [sseConnected, setSseConnected] = useState(false); - const eventSourceRef = useRef(null); - const scrollRef = useRef(null); - - const _loadSteps = useCallback(async () => { - setLoading(true); - try { - const resp = await api.get(`/api/system/workflow-runs/${run.id}/steps`); - setSteps(resp.data?.steps || []); - } catch (e) { - console.error('[RunTracing] Failed to load steps:', e); - } finally { - setLoading(false); - } - }, [run.id]); - - const isRunning = run.status === 'running' || run.status === 'paused'; - - useEffect(() => { - _loadSteps(); - - if (!isRunning) return; - - const baseUrl = api.defaults.baseURL || ''; - const url = `${baseUrl}/api/system/workflow-runs/${run.id}/stream`; - const es = new EventSource(url, { withCredentials: true }); - eventSourceRef.current = es; - - es.onopen = () => setSseConnected(true); - es.onmessage = (event) => { - try { - const payload = JSON.parse(event.data); - if (payload.type === 'keepalive') return; - if (payload.type === 'run_complete' || payload.type === 'run_failed') { - _loadSteps(); - es.close(); - setSseConnected(false); - return; - } - if (payload.status === 'running') { - setSteps((prev) => { - const exists = prev.some((s) => s.id === payload.id); - if (exists) return prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s); - return [...prev, payload as _TracingStep]; - }); - } else { - setSteps((prev) => prev.map((s) => s.id === payload.id ? { ...s, ...payload } : s)); - } - } catch { /* ignore parse errors */ } - }; - es.onerror = () => { - setSseConnected(false); - es.close(); - }; - - return () => { - es.close(); - eventSourceRef.current = null; - setSseConnected(false); - }; - }, [run.id, run.status]); // eslint-disable-line react-hooks/exhaustive-deps - - useEffect(() => { - if (!isRunning) return; - const interval = setInterval(() => { _loadSteps(); }, 5000); - return () => clearInterval(interval); - }, [isRunning, _loadSteps]); - - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [steps]); - - return ( -
-
-
-
-

- {t('Run-Tracing')}: {run.workflowLabel || run.workflowId} -

-
- - {run.status} - - {sseConnected && ( - ● {t('Live')} - )} -
-
- -
-
- {loading && steps.length === 0 && ( -
{t('Wird geladen…')}
- )} - {!loading && steps.length === 0 && ( -
{t('Noch keine Schritte aufgezeichnet')}
- )} - {steps.map((step) => { - const startStr = _formatStepTs(step.startedAt); - const endStr = _formatStepTs(step.completedAt); - const inputStr = _truncateJson(step.inputSnapshot); - const outputStr = _truncateJson(step.output); - const isLoop = step.inputSnapshot?._loopIndex != null; - - return ( -
-
- - - {_STATUS_ICONS[step.status] || '?'} - - {step.nodeType} - ({step.nodeId}) - {isLoop && ( - - [iter {step.inputSnapshot!._loopIndex}] - - )} - - - {(step.retryCount ?? 0) > 0 && ( - - {step.retryCount}x {t('Wiederholung')} - - )} - {step.durationMs != null && ( - {step.durationMs}ms - )} - -
- {(startStr || endStr) && ( -
- {startStr && {startStr}} - {startStr && endStr && } - {endStr && {endStr}} -
- )} - {step.error && ( -
{step.error}
- )} - {(step.tokensUsed ?? 0) > 0 && ( -
- {step.tokensUsed} {t('Tokens')} -
- )} - <_CollapsibleSection label={t('Eingabe')} content={inputStr} /> - <_CollapsibleSection label={t('Ausgabe')} content={outputStr} /> -
- ); - })} -
-
-
- ); -}; - -// =========================================================================== -// DashboardTab — Metrics + Runs table with backend pagination -// =========================================================================== - -interface _DashboardTabProps { - workflowFilter?: string | null; - onRunClick?: (runId: string) => void; -} - -const _DashboardTab: React.FC<_DashboardTabProps> = ({ workflowFilter, onRunClick }) => { - const { t } = useLanguage(); - const { request } = useApiRequest(); - const { showError } = useToast(); - - const [metrics, setMetrics] = useState(null); - const [runs, setRuns] = useState([]); - const [loading, setLoading] = useState(true); - const [paginationMeta, setPaginationMeta] = useState(null); - const [tracingRun, setTracingRun] = useState(null); - const lastPaginationParamsRef = useRef(null); - const [backendAttributes, setBackendAttributes] = useState([]); - - useEffect(() => { - fetchAttributes(request, 'AutoRun') - .then(setBackendAttributes) - .catch((err) => { console.error('[automations] fetchAttributes AutoRun failed', err); }); - }, [request]); - - const _loadMetrics = useCallback(async () => { - try { - const resp = await api.get('/api/system/workflow-runs/metrics'); - setMetrics(resp.data); - } catch (e: any) { - const msg = e?.response?.data?.detail || e?.message || String(e); - console.error('[automations] metrics load failed', e); - showError(t('Metriken konnten nicht geladen werden: {msg}', { msg })); - } - }, [showError, t]); - - const _loadRuns = useCallback(async (paginationParams?: any) => { - if (paginationParams !== undefined) { - lastPaginationParamsRef.current = paginationParams; - } - const effectiveParams = paginationParams ?? lastPaginationParamsRef.current; - setLoading(true); - try { - const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }]; - const pag = { - page: effectiveParams?.page || 1, - pageSize: effectiveParams?.pageSize || 25, - sort: effectiveParams?.sort || defaultSort, - ...(effectiveParams?.search ? { search: effectiveParams.search } : {}), - ...(effectiveParams?.filters ? { filters: effectiveParams.filters } : {}), - }; - const params: Record = { pagination: JSON.stringify(pag) }; - const resp = await api.get('/api/system/workflow-runs', { params }); - const data = resp.data; - setRuns(data?.runs || []); - const total = data?.total ?? 0; - const pageSize = pag.pageSize; - setPaginationMeta({ - currentPage: pag.page, - pageSize, - totalItems: total, - totalPages: Math.ceil(total / pageSize), - }); - } catch (e) { - console.error('[automations] runs load failed', e); - showError(t('Fehler beim Laden der Workflow-Runs')); - } finally { - setLoading(false); - } - }, [showError, t]); - - useEffect(() => { - _loadMetrics(); - }, [_loadMetrics]); - - const hasRunningRuns = runs.some((r) => r.status === 'running' || r.status === 'paused'); - useEffect(() => { - if (!hasRunningRuns) return; - const interval = setInterval(() => { - _loadRuns(); - _loadMetrics(); - }, 5000); - return () => clearInterval(interval); - }, [hasRunningRuns, _loadRuns, _loadMetrics]); - - const _downloadRunTracing = useCallback(async (run: WorkflowRun) => { - if (!run.id) return; - try { - const resp = await api.get(`/api/system/workflow-runs/${run.id}/steps`); - const steps = resp.data?.steps || []; - const report = { - runId: run.id, - workflowId: run.workflowId, - workflowLabel: run.workflowLabel, - status: run.status, - startedAt: _formatTs(run.sysCreatedAt), - endedAt: _formatTs(run.sysModifiedAt), - steps, - }; - const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `run-tracing-${run.id.slice(0, 8)}.json`; - a.click(); - URL.revokeObjectURL(url); - } catch (e) { - console.error('[automations] download tracing failed', e); - showError(t('Download fehlgeschlagen')); - } - }, [showError, t]); - - const _initialFilters = useMemo(() => { - if (!workflowFilter) return undefined; - return { workflowId: workflowFilter }; - }, [workflowFilter]); - - const _rawRunColumns: ColumnConfig[] = useMemo(() => [ - { - key: 'workflowId', - label: t('Workflow'), - width: 200, - sortable: true, - filterable: true, - displayField: 'workflowLabel', - }, - { - key: 'mandateId', - label: t('Mandant'), - width: 140, - sortable: true, - filterable: true, - displayField: 'mandateLabel', - }, - { - key: 'featureInstanceId', - label: t('Instanz'), - width: 140, - sortable: true, - filterable: true, - displayField: 'instanceLabel', - }, - { - key: 'ownerId', - label: t('Benutzer'), - width: 140, - sortable: true, - filterable: true, - displayField: 'ownerLabel', - }, - { key: 'status', width: 110, sortable: true, filterable: true }, - { - key: 'startedAt', - label: t('Gestartet'), - width: 150, - sortable: true, - filterable: true, - formatter: (v: number) => _formatTs(v), - }, - { - key: 'completedAt', - label: t('Beendet'), - width: 150, - sortable: true, - filterable: true, - formatter: (v: number) => _formatTs(v), - }, - ], [t]); - - const _runColumns = useMemo( - () => resolveColumnTypes(_rawRunColumns, backendAttributes), - [_rawRunColumns, backendAttributes], - ); - - const _hookData = useMemo(() => ({ - refetch: _loadRuns, - pagination: paginationMeta, - }), [_loadRuns, paginationMeta]); - - return ( - <> -
-
-

{t('Workflow-Runs über alle Features und Mandanten')}

-
-
- -
-
- -
- } label={t('Workflows')} value={metrics?.workflowCount ?? t('—')} /> - } label={t('Aktive Workflows')} value={metrics?.activeWorkflows ?? t('—')} color="var(--success-color, #28a745)" /> - } label={t('Runs gesamt')} value={metrics?.totalRuns ?? t('—')} /> -
- - {metrics?.runsByStatus && Object.keys(metrics.runsByStatus).length > 0 && ( -
-

{t('Läufe nach Status')}

-
- {Object.entries(metrics.runsByStatus).map(([status, count]) => ( - - {status}: {count} - - ))} -
-
- )} - - {metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && ( -
- {metrics.totalTokens > 0 && ( -
- {t('Tokens gesamt:')} - {metrics.totalTokens.toLocaleString('de-DE')} -
- )} - {metrics.totalCredits > 0 && ( -
- {t('Credits gesamt:')} - {metrics.totalCredits.toLocaleString('de-DE', { minimumFractionDigits: 2 })} -
- )} -
- )} - -
-

{t('Letzte Runs')}

-
-
- - data={runs} - columns={_runColumns} - loading={loading} - pagination={true} - pageSize={25} - searchable={true} - filterable={true} - sortable={true} - selectable={true} - initialSort={[{ key: 'startedAt', direction: 'desc' }]} - initialFilters={_initialFilters} - apiEndpoint="/api/system/workflow-runs" - onRowClick={(row) => onRunClick?.(row.id)} - customActions={[ - { - id: 'tracing', - icon: , - title: t('Run-Tracing anzeigen'), - onClick: (row) => setTracingRun(row), - }, - { - id: 'download', - icon: , - title: t('Tracing-Protokoll herunterladen'), - onClick: (row) => _downloadRunTracing(row), - }, - ]} - hookData={_hookData} - emptyMessage={t('Noch keine Workflow-Runs vorhanden.')} - /> -
- {tracingRun && ( - <_RunTracingModal run={tracingRun} onClose={() => setTracingRun(null)} /> - )} - - ); -}; - -// =========================================================================== -// WorkflowsTab — Central workflow management across all instances -// =========================================================================== - -interface _WorkflowsTabProps { - onWorkflowClick?: (workflowId: string) => void; -} - -const _WorkflowsTab: React.FC<_WorkflowsTabProps> = ({ onWorkflowClick }) => { - const { t } = useLanguage(); - const navigate = useNavigate(); - const { request } = useApiRequest(); - const { showSuccess, showError } = useToast(); - const { prompt: promptInput, PromptDialog } = usePrompt(); - const { dynamicBlock } = useNavigation(); - - const [workflows, setWorkflows] = useState([]); - const [loading, setLoading] = useState(true); - const [executingId, setExecutingId] = useState(null); - const [togglingId, setTogglingId] = useState(null); - const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all'); - const [paginationMeta, setPaginationMeta] = useState(null); - const lastPaginationParamsRef = useRef(null); - const [backendAttributes, setBackendAttributes] = useState([]); - - useEffect(() => { - fetchAttributes(request, 'Automation2WorkflowView') - .then(setBackendAttributes) - .catch((err) => { console.error('[automations] fetchAttributes Automation2WorkflowView failed', err); }); - }, [request]); - - const _load = useCallback(async (paginationParams?: any) => { - if (paginationParams !== undefined) { - lastPaginationParamsRef.current = paginationParams; - } - const effectiveParams = paginationParams ?? lastPaginationParamsRef.current; - setLoading(true); - try { - const params: Record = {}; - if (activeFilter === 'active') params.active = true; - if (activeFilter === 'inactive') params.active = false; - - const defaultSort = [{ field: 'sysCreatedAt', direction: 'desc' }]; - const pag = { - page: effectiveParams?.page || 1, - pageSize: effectiveParams?.pageSize || 25, - sort: effectiveParams?.sort || defaultSort, - ...(effectiveParams?.search ? { search: effectiveParams.search } : {}), - ...(effectiveParams?.filters ? { filters: effectiveParams.filters } : {}), - }; - params.pagination = JSON.stringify(pag); - - const resp = await api.get('/api/system/workflow-runs/workflows', { params }); - const data = resp.data; - setWorkflows(data?.items || []); - setPaginationMeta(data?.pagination || null); - } catch (e) { - console.error('[automations] load system workflows failed', e); - showError(t('Fehler beim Laden der Workflows')); - } finally { - setLoading(false); - } - }, [activeFilter, showError, t]); - - useEffect(() => { - _load(); - }, [_load]); - - const hasRunningWorkflows = workflows.some((w) => w.isRunning); - useEffect(() => { - if (!hasRunningWorkflows) return; - const interval = setInterval(() => { _load(); }, 5000); - return () => clearInterval(interval); - }, [hasRunningWorkflows, _load]); - - const _handleEdit = useCallback((row: SystemWorkflow) => { - if (!row.mandateId) return; - const fc = row.featureCode || ''; - if (_FEATURES_WITH_EDITOR.has(fc)) { - navigate(`/mandates/${row.mandateId}/${fc}/${row.featureInstanceId}/editor?workflowId=${row.id}`); - return; - } - const editor = _bestEditorInstance(dynamicBlock, row.mandateId); - if (!editor) { - showError(t('Kein Editor verfügbar für diesen Mandanten')); - return; - } - navigate(`/mandates/${row.mandateId}/${editor.featureCode}/${editor.instanceId}/editor?workflowId=${row.id}`); - }, [navigate, showError, t, dynamicBlock]); - - const _handleDelete = useCallback(async (workflowId: string): Promise => { - try { - await deleteSystemWorkflow(request, workflowId); - showSuccess(t('Workflow gelöscht')); - await _load(); - return true; - } catch (e: any) { - showError(t('Fehler: {msg}', { msg: e?.message || t('Löschen fehlgeschlagen') })); - return false; - } - }, [request, showSuccess, showError, _load, t]); - - const _handleToggleActive = useCallback(async (row: SystemWorkflow) => { - if (!row.featureInstanceId) return; - const next = !(row.active !== false); - setTogglingId(row.id); - try { - await updateWorkflow(request, row.featureInstanceId, row.id, { active: next }); - showSuccess(next ? t('Workflow aktiviert') : t('Workflow deaktiviert')); - await _load(); - } catch (e: any) { - showError(t('Fehler: {msg}', { msg: e?.message || t('Status-Update fehlgeschlagen') })); - } finally { - setTogglingId(null); - } - }, [request, showSuccess, showError, _load, t]); - - const _handleRename = useCallback(async (row: SystemWorkflow) => { - if (!row.featureInstanceId) return; - const newLabel = await promptInput(t('Neuer Name:'), { - title: t('Workflow umbenennen'), - defaultValue: row.label, - placeholder: t('Workflow-Name'), - }); - if (!newLabel || newLabel.trim() === row.label) return; - try { - await updateWorkflow(request, row.featureInstanceId, row.id, { label: newLabel.trim() }); - showSuccess(t('Workflow umbenannt')); - await _load(); - } catch (e: any) { - showError(t('Fehler: {msg}', { msg: e?.message || t('Umbenennen fehlgeschlagen') })); - } - }, [request, promptInput, showSuccess, showError, _load, t]); - - const _handleExecute = useCallback(async (row: SystemWorkflow) => { - if (!row.featureInstanceId) return; - setExecutingId(row.id); - let observedFailure = false; - let observedSuccess = false; - try { - const invs = row.invocations || []; - const primary = - invs.find((i) => i.enabled && i.kind === 'manual') || - invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api')); - const emptyGraph = { nodes: [], connections: [] }; - const exec = executeGraph(request, row.featureInstanceId, emptyGraph as any, row.id, { - ...(primary ? { entryPointId: primary.id } : {}), - }).then((result) => { - if (result?.success) { - observedSuccess = true; - showSuccess(result?.paused - ? t('Workflow pausiert bei Human Task.') - : t('Workflow abgeschlossen')); - } else { - observedFailure = true; - showError(result?.error || t('Ausführung fehlgeschlagen')); - } - _load(); - }).catch((e: any) => { - observedFailure = true; - showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') })); - _load(); - }); - await Promise.race([ - exec, - new Promise((r) => setTimeout(r, 1000)), - ]); - await _load(); - if (!observedFailure && !observedSuccess) { - showSuccess(t('Workflow gestartet')); - } - } finally { - setExecutingId(null); - } - }, [request, showSuccess, showError, _load, t]); - - const [stoppingId, setStoppingId] = useState(null); - - const _handleStop = useCallback(async (row: SystemWorkflow) => { - if (!row.activeRunId) return; - setStoppingId(row.id); - try { - await api.post(`/api/system/workflow-runs/${row.activeRunId}/stop`); - showSuccess(t('Stop-Signal gesendet')); - await _load(); - } catch (e: any) { - showError(t('Fehler: {msg}', { msg: e?.message || t('Stoppen fehlgeschlagen') })); - } finally { - setStoppingId(null); - } - }, [showSuccess, showError, _load, t]); - - const _hasManualTrigger = useCallback((row: SystemWorkflow): boolean => { - const invs = row.invocations || []; - return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api')); - }, []); - - const _rawColumns: ColumnConfig[] = useMemo(() => [ - { key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true }, - { - key: 'mandateId', - label: t('Mandant'), - width: 140, - sortable: true, - filterable: true, - displayField: 'mandateLabel', - }, - { - key: 'featureInstanceId', - label: t('Instanz'), - width: 140, - sortable: true, - filterable: true, - displayField: 'instanceLabel', - }, - { - key: 'ownerId', - label: t('Benutzer'), - width: 140, - sortable: true, - filterable: true, - displayField: 'ownerLabel', - }, - { - key: 'active', - label: t('Aktiv'), - width: 80, - sortable: true, - filterable: true, - }, - { - key: 'isRunning', - label: t('Läuft'), - width: 80, - sortable: true, - filterable: true, - }, - { - key: 'sysCreatedAt', - label: t('Erstellt'), - width: 140, - sortable: true, - filterable: true, - formatter: (v: number) => _formatTs(v), - }, - { - key: 'lastStartedAt', - label: t('Zuletzt gestartet'), - width: 160, - sortable: true, - filterable: true, - formatter: (v: number) => _formatTs(v), - }, - { - key: 'runCount', - label: t('Läufe'), - width: 80, - sortable: true, - filterable: true, - formatter: (v: number) => (v != null ? String(v) : '0'), - }, - ], [t]); - - const _columns = useMemo( - () => resolveColumnTypes(_rawColumns, backendAttributes), - [_rawColumns, backendAttributes], - ); - - const _hookData = useMemo(() => ({ - refetch: _load, - handleDelete: (id: string) => _handleDelete(id), - pagination: paginationMeta, - }), [_load, _handleDelete, paginationMeta]); - - return ( - <> -
-
-

- {t('Alle Workflows über alle Features und Mandanten')} -

-
-
-
- {(['all', 'active', 'inactive'] as const).map((f) => ( - - ))} -
- -
-
- -
- - data={workflows} - columns={_columns} - loading={loading} - pagination={true} - pageSize={25} - searchable={true} - filterable={true} - sortable={true} - selectable={true} - initialSort={[{ key: 'sysCreatedAt', direction: 'desc' }]} - apiEndpoint="/api/system/workflow-runs/workflows" - actionButtons={[ - { - type: 'edit', - title: t('bearbeiten'), - onAction: _handleEdit, - visible: (row: SystemWorkflow) => row.canEdit === true, - }, - { - type: 'delete', - title: t('löschen'), - visible: (row: SystemWorkflow) => row.canDelete === true, - }, - ]} - customActions={[ - { - id: 'view', - icon: , - title: t('anzeigen'), - onClick: (row) => _handleEdit(row), - visible: (row) => row.canEdit !== true, - }, - { - id: 'rename', - icon: , - title: t('umbenennen'), - onClick: (row) => _handleRename(row), - visible: (row) => row.canEdit === true, - }, - { - id: 'activate', - icon: , - title: t('aktivieren'), - onClick: (row) => _handleToggleActive(row), - loading: (row) => togglingId === row.id, - visible: (row) => row.canEdit === true && row.active === false, - }, - { - id: 'deactivate', - icon: , - title: t('deaktivieren'), - onClick: (row) => _handleToggleActive(row), - loading: (row) => togglingId === row.id, - visible: (row) => row.canEdit === true && row.active !== false, - }, - { - id: 'execute', - icon: , - title: t('ausführen'), - onClick: (row) => _handleExecute(row), - loading: (row) => executingId === row.id, - visible: (row) => row.canExecute === true && _hasManualTrigger(row) && !row.isRunning, - }, - { - id: 'stop', - icon: , - title: t('stoppen'), - onClick: (row) => _handleStop(row), - loading: (row) => stoppingId === row.id, - visible: (row) => row.isRunning === true && !!row.activeRunId, - }, - ]} - onDelete={(row) => _handleDelete(row.id)} - onRowClick={(row) => onWorkflowClick?.(row.id)} - hookData={_hookData} - emptyMessage={t('Keine Workflows gefunden.')} - /> -
- - - ); -}; - -// =========================================================================== -// Workspace Tab (run detail only — no table) -// =========================================================================== - -const _FILE_REF_KEYS = new Set(['fileId', 'documentId', 'fileIds', 'documents']); - -function _isPlainObject(v: unknown): v is Record { - return typeof v === 'object' && v !== null && !Array.isArray(v); -} - -function _stripFileRefKeys(value: unknown): unknown { - if (_isPlainObject(value)) { - const out: Record = {}; - for (const [k, v] of Object.entries(value)) { - if (_FILE_REF_KEYS.has(k)) continue; - const stripped = _stripFileRefKeys(v); - if (stripped !== undefined) out[k] = stripped; - } - return Object.keys(out).length > 0 ? out : undefined; - } - if (Array.isArray(value)) { - const out = value.map((v) => _stripFileRefKeys(v)).filter((v) => v !== undefined); - return out.length > 0 ? out : undefined; - } - return value; -} - -function _formatScalar(v: unknown): string { - if (v === null || v === undefined) return '—'; - if (typeof v === 'string') return v; - if (typeof v === 'number' || typeof v === 'boolean') return String(v); - return JSON.stringify(v); -} - -const _DataBlock: React.FC<{ data: unknown; emptyHint?: string }> = ({ data, emptyHint }) => { - if (data === undefined || data === null) { - return emptyHint ?

{emptyHint}

: null; - } - - if (_isPlainObject(data)) { - const entries = Object.entries(data); - if (entries.length === 0) { - return emptyHint ?

{emptyHint}

: null; - } - return ( -
- {entries.map(([k, v]) => { - const isComplex = _isPlainObject(v) || Array.isArray(v); - if (isComplex) { - return ( -
- - {k} - -
-                  {JSON.stringify(v, null, 2)}
-                
-
- ); - } - return ( -
- {k} - {_formatScalar(v)} -
- ); - })} -
- ); - } - - return ( -
-      {JSON.stringify(data, null, 2)}
-    
- ); -}; - -const _FileLinkList: React.FC<{ files: Array<{ id: string; fileName?: string }> }> = ({ files }) => { - if (!files.length) return null; - const baseUrl = api.defaults.baseURL || ''; - return ( -
- {files.map((f) => ( - - - {f.fileName || f.id} - - ))} -
- ); -}; - -const _INTERNAL_EXTRACT_FILENAME_SUBSTR = 'extracted_content_transient'; - -function _isHiddenWorkflowArtifactFile(f: { fileName?: string }): boolean { - return (f.fileName ?? '').toLowerCase().includes(_INTERNAL_EXTRACT_FILENAME_SUBSTR); -} - -const _ProducedFilesSection: React.FC<{ - steps: Array<{ outputFiles?: Array<{ id: string; fileName?: string }> }>; - unassignedFiles?: Array<{ id: string; fileName?: string }>; -}> = ({ steps, unassignedFiles }) => { - const { t } = useLanguage(); - const seen = new Set(); - const allFiles: Array<{ id: string; fileName?: string }> = []; - for (const step of steps) { - for (const f of step.outputFiles ?? []) { - if (_isHiddenWorkflowArtifactFile(f)) continue; - if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); } - } - } - for (const f of unassignedFiles ?? []) { - if (_isHiddenWorkflowArtifactFile(f)) continue; - if (!seen.has(f.id)) { seen.add(f.id); allFiles.push(f); } - } - if (!allFiles.length) return null; - const baseUrl = api.defaults.baseURL || ''; - return ( -
-
- - {t('Ergebnisse')} ({allFiles.length}) -
-
- {allFiles.map(f => ( - - - {f.fileName || f.id} - - ))} -
-
- ); -}; - -function _downloadJson(data: unknown, fileName: string) { - const json = JSON.stringify(data, null, 2); - const blob = new Blob([json], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = fileName; - a.click(); - URL.revokeObjectURL(url); -} - -interface _WorkspaceTabProps { - runId: string | null; - onBack: () => void; -} - -const _TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'error', 'stopped']); -const _POLL_INTERVAL_MS = 3000; - -const _WorkspaceTab: React.FC<_WorkspaceTabProps> = ({ runId, onBack }) => { - const { t } = useLanguage(); - const { request } = useApiRequest(); - const [runDetail, setRunDetail] = useState> | null>(null); - const [detailLoading, setDetailLoading] = useState(false); - - const _loadDetail = useCallback(async (id: string) => { - setDetailLoading(true); - try { - const detail = await fetchWorkspaceRunDetail(request, id); - setRunDetail(detail); - } catch (e) { - console.error('Workspace run detail failed', e); - } finally { - setDetailLoading(false); - } - }, [request]); - - useEffect(() => { - if (runId) _loadDetail(runId); - else setRunDetail(null); - }, [runId, _loadDetail]); - - useEffect(() => { - if (!runId || !runDetail) return; - const status = runDetail.run?.status; - if (status && _TERMINAL_STATUSES.has(status)) return; - const timer = setInterval(() => { - fetchWorkspaceRunDetail(request, runId) - .then(detail => setRunDetail(detail)) - .catch(() => {}); - }, _POLL_INTERVAL_MS); - return () => clearInterval(timer); - }, [runId, runDetail, request]); - - if (!runId) { - return ( -
-

{t('Wähle einen Run im Dashboard aus, um die Details anzuzeigen.')}

-
- ); - } - - if (detailLoading || !runDetail) { - return

{t('Laden…')}

; - } - - const { run, steps, workflow, unassignedFiles } = runDetail; - - return ( -
- -

{run.workflowLabel || run.workflowId}

-
- {t('Status')}: {run.status} - {run.startedAt && {t('Start')}: {_formatTs(run.startedAt)}} - {run.completedAt && {t('Ende')}: {_formatTs(run.completedAt)}} - {workflow?.targetFeatureInstanceId && {t('Ziel-Instanz')}: {run.targetInstanceLabel || workflow.targetFeatureInstanceId}} - {(run.costTokens ?? 0) > 0 && Tokens: {run.costTokens}} -
- {run.error && ( -
- {run.error} -
- )} - <_ProducedFilesSection steps={steps} unassignedFiles={unassignedFiles} /> -

{t('Schritte')}

- {steps.length === 0 ? ( -

{t('Keine Schritte protokolliert.')}

- ) : ( -
- {steps.map((step) => { - const inputData = _stripFileRefKeys(step.inputSnapshot ?? {}); - const outputData = _stripFileRefKeys(step.output ?? {}); - const inputFiles = (step.inputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f)); - const outputFiles = (step.outputFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f)); - const hasInput = inputData !== undefined || inputFiles.length > 0; - const hasOutput = outputData !== undefined || outputFiles.length > 0; - return ( -
- - - {step.status} - - {step.nodeType} ({step.nodeId}) - {step.durationMs != null && {step.durationMs}ms} - {(step.tokensUsed ?? 0) > 0 && {step.tokensUsed} tokens} - -
- {hasInput && ( -
-
- {t('Input')} - {inputData !== undefined && inputData !== null && ( - - )} -
- <_DataBlock data={inputData} /> - <_FileLinkList files={inputFiles} /> -
- )} - {hasOutput && ( -
-
- {t('Output')} - {outputData !== undefined && outputData !== null && ( - - )} -
- <_DataBlock data={outputData} /> - <_FileLinkList files={outputFiles} /> -
- )} - {step.error && ( -
-
- {t('Fehler')} -
-

{step.error}

-
- )} -
- {step.startedAt && {t('Start')}: {_formatTs(step.startedAt)}} - {step.completedAt && {t('Ende')}: {_formatTs(step.completedAt)}} - {(step.retryCount ?? 0) > 0 && {t('Wiederholungen')}: {step.retryCount}} -
-
-
- ); - })} -
- )} - {(() => { - const visibleUnassigned = (unassignedFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f)); - if (!visibleUnassigned.length) return null; - return ( - <> -

{t('Sonstige Dokumente')}

- <_FileLinkList files={visibleUnassigned} /> - - ); - })()} -
- ); -}; - -// =========================================================================== -// Editor Tab — wraps GraphicalEditorPage with auto-resolved instanceId -// =========================================================================== - -const _EditorTab: React.FC = () => { - const { t } = useLanguage(); - const { dynamicBlock } = useNavigation(); - - const editorInstance = useMemo( - () => _findAnyEditorInstance(dynamicBlock), - [dynamicBlock], - ); - - if (!editorInstance) { - return ( -
-

{t('Kein Editor verfügbar. Bitte erstelle zuerst eine Feature-Instanz mit Editor (Workspace oder Graphical Editor).')}

-
- ); - } - - return ( -
- -
- ); -}; - -// =========================================================================== -// Templates Tab — wraps GraphicalEditorTemplatesPage with auto-resolved instanceId -// =========================================================================== - -const _TemplatesTab: React.FC = () => { - const { t } = useLanguage(); - const { dynamicBlock } = useNavigation(); - - const editorInstance = useMemo( - () => _findAnyEditorInstance(dynamicBlock), - [dynamicBlock], - ); - - if (!editorInstance) { - return ( -
-

{t('Kein Editor verfügbar. Bitte erstelle zuerst eine Feature-Instanz mit Editor.')}

-
- ); - } - - return ( - - ); -}; - -// =========================================================================== -// Main page with Tabs -// =========================================================================== - -const _TAB_ALIASES: Record = { - dashboard: 'runs', - workspace: 'detail', -}; - -export const WorkflowAutomationPage: React.FC = () => { - const { t } = useLanguage(); - const [searchParams] = useSearchParams(); - - const rawTab = searchParams.get('tab') || 'workflows'; - const initialTab = _TAB_ALIASES[rawTab] || rawTab; - const initialRunId = searchParams.get('runId') || null; - - const [activeTab, setActiveTab] = useState(initialRunId ? 'detail' : initialTab); - const [selectedRunId, setSelectedRunId] = useState(initialRunId); - const [workflowFilter, setWorkflowFilter] = useState(null); - - const _handleWorkflowClick = useCallback((workflowId: string) => { - setWorkflowFilter(workflowId); - setActiveTab('runs'); - }, []); - - useEffect(() => { - if (workflowFilter) setWorkflowFilter(null); - }, [workflowFilter]); - - const _handleRunClick = useCallback((runId: string) => { - setSelectedRunId(runId); - setActiveTab('detail'); - }, []); - - const _handleBackFromWorkspace = useCallback(() => { - setSelectedRunId(null); - setActiveTab('runs'); - }, []); - - const tabs = useMemo(() => [ - { - id: 'workflows', - label: t('Workflows'), - content: <_WorkflowsTab onWorkflowClick={_handleWorkflowClick} />, - }, - { - id: 'editor', - label: t('Editor'), - content: <_EditorTab />, - }, - { - id: 'templates', - label: t('Vorlagen'), - content: <_TemplatesTab />, - }, - { - id: 'runs', - label: t('Workflow-Durchläufe'), - content: <_DashboardTab workflowFilter={workflowFilter} onRunClick={_handleRunClick} />, - }, - { - id: 'detail', - label: t('Durchlauf-Details'), - content: <_WorkspaceTab runId={selectedRunId} onBack={_handleBackFromWorkspace} />, - }, - ], [t, _handleWorkflowClick, workflowFilter, _handleRunClick, selectedRunId, _handleBackFromWorkspace]); - - return ( -
-

{t('Workflow-Automation')}

- -
- ); -}; - -export default WorkflowAutomationPage; diff --git a/src/pages/views/graphicalEditor/Automation2WorkflowsTasks.module.css b/src/pages/views/graphicalEditor/Automation2WorkflowsTasks.module.css deleted file mode 100644 index 47c2c39..0000000 --- a/src/pages/views/graphicalEditor/Automation2WorkflowsTasks.module.css +++ /dev/null @@ -1,513 +0,0 @@ -.pageLayout { - display: flex; - align-items: flex-start; - gap: 1.5rem; - padding: 1.5rem; - max-width: 1400px; -} - -.mainColumn { - flex: 1; - min-width: 0; -} - -.startSidebar { - flex: 0 0 300px; - position: sticky; - top: 1rem; - max-height: calc(100vh - 2rem); - display: flex; - flex-direction: column; - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 8px; - background: var(--bg-secondary, #f8f9fa); - overflow: hidden; -} - -.startSidebarTitle { - margin: 0; - padding: 0.75rem 1rem; - font-size: 0.95rem; - font-weight: 600; - border-bottom: 1px solid var(--border-color, #e0e0e0); - background: var(--bg-primary, #fff); -} - -.startSidebarList { - margin: 0; - padding: 0.5rem; - overflow-y: auto; - flex: 1; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.startWorkflowRow { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.6rem 0.65rem; - border-radius: 6px; - background: var(--bg-primary, #fff); - border: 1px solid var(--border-color, #e0e0e0); -} - -.startWorkflowInfo { - flex: 1; - min-width: 0; - display: flex; - flex-direction: column; - gap: 0.2rem; -} - -.startWorkflowName { - font-size: 0.875rem; - font-weight: 500; - color: var(--text-primary, #333); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.startWorkflowKind { - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - color: var(--text-secondary, #666); -} - -.startButton { - flex-shrink: 0; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.4rem 0.65rem; - border: none; - border-radius: 6px; - background: var(--primary-color, #007bff); - color: white; - cursor: pointer; - font-size: 0.8rem; -} - -.startButton:hover:not(:disabled) { - opacity: 0.9; -} - -.startButton:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -@media (max-width: 900px) { - .pageLayout { - flex-direction: column; - } - - .startSidebar { - position: static; - max-height: none; - width: 100%; - flex: none; - } -} - -.container { - padding: 0; - width: 100%; -} - -.container h2 { - margin: 0 0 1rem 0; - font-size: 1.25rem; -} - -.section { - margin-bottom: 1.5rem; -} - -.sectionTitle { - display: flex; - align-items: center; - gap: 0.5rem; - margin: 0 0 0.75rem 0; - font-size: 1rem; - font-weight: 600; -} - -.completedHeader { - display: flex; - align-items: center; - gap: 0.5rem; - width: 100%; - padding: 0.6rem 0; - text-align: left; - background: none; - border: none; - cursor: pointer; - font-size: 1rem; - font-weight: 600; - color: var(--text-primary, #333); -} - -.completedHeader:hover { - color: var(--primary-color, #007bff); -} - -.completedList { - max-height: 360px; - overflow-y: auto; - padding-top: 0.5rem; -} - -.taskMeta { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); - gap: 0.5rem 1.25rem; - margin-bottom: 0.75rem; - padding-bottom: 0.75rem; - border-bottom: 1px solid var(--border-color, #e0e0e0); -} - -.taskMetaRow { - display: flex; - flex-direction: column; - gap: 0.2rem; -} - -.metaLabel { - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - color: var(--text-secondary, #666); -} - -.metaValue { - font-size: 0.9rem; - color: var(--text-primary, #333); -} - -.metaValueMono { - font-size: 0.75rem; - font-family: monospace; - color: var(--text-secondary, #666); -} - -.loading { - display: flex; - flex-direction: column; - align-items: center; - gap: 1rem; - padding: 3rem; - color: var(--text-secondary, #666); -} - -.spinner { - animation: spin 1s linear infinite; -} - -@keyframes spin { - from { transform: rotate(0deg); } - to { transform: rotate(360deg); } -} - -.placeholder { - padding: 2rem; - text-align: center; - color: var(--text-secondary, #666); -} - -.workflowList { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.workflowItem { - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 8px; - overflow: hidden; - background: var(--bg-primary, #fff); -} - -.workflowHeader { - display: flex; - align-items: center; - gap: 0.5rem; - width: 100%; - padding: 0.75rem 1rem; - text-align: left; - background: var(--bg-secondary, #f8f9fa); - border: none; - cursor: pointer; - font-size: 1rem; -} - -.workflowHeader:hover { - background: var(--bg-hover, #e9ecef); -} - -.badge { - margin-left: auto; - background: var(--primary-color, #007bff); - color: white; - padding: 0.2rem 0.5rem; - border-radius: 12px; - font-size: 0.8rem; -} - -.taskList { - padding: 1rem; - border-top: 1px solid var(--border-color, #e0e0e0); -} - -.empty { - color: var(--text-tertiary, #999); - font-size: 0.9rem; - margin: 0; -} - -.taskCard { - padding: 1rem; - margin-bottom: 0.75rem; - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 6px; - background: var(--bg-primary, #fff); -} - -.taskCardDismissable { - position: relative; - padding-top: 0.85rem; - padding-right: 2.25rem; -} - -.dismissOpenTaskBtn { - position: absolute; - top: 0.35rem; - right: 0.35rem; - width: 1.85rem; - height: 1.85rem; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0; - border: none; - border-radius: 50%; - background: transparent; - color: var(--text-secondary, #888); - cursor: pointer; - font-size: 1rem; - line-height: 1; -} - -.dismissOpenTaskBtn:hover:not(:disabled) { - color: var(--danger-color, #c82333); - background: rgba(220, 53, 69, 0.08); -} - -.dismissOpenTaskBtn:focus-visible { - outline: 2px solid var(--primary-color, #007bff); - outline-offset: 2px; -} - -.dismissOpenTaskBtn:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.taskCard:last-child { - margin-bottom: 0; -} - -.taskType { - font-size: 0.75rem; - font-weight: 600; - text-transform: uppercase; - color: var(--text-secondary, #666); - margin-bottom: 0.5rem; -} - -.formFields { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.formFields button { - margin-top: 0.75rem; - align-self: flex-start; -} - -.formFields label, -.taskCard label { - display: block; - font-size: 0.875rem; - margin-top: 0.5rem; - margin-bottom: 0.25rem; -} - -.formFields input[type='text'], -.formFields input[type='number'], -.formFields input[type='date'], -.formFields select, -.taskCard input[type='text'], -.taskCard input[type='number'], -.taskCard textarea { - width: 100%; - padding: 0.5rem; - border: 1px solid var(--border-color, #e0e0e0); - border-radius: 4px; -} - -.taskCard textarea { - min-height: 80px; - margin-bottom: 0.5rem; -} - -.openFormButton { - margin-top: 0.5rem; - padding: 0.5rem 1rem; - background: var(--primary-color, #007bff); - color: white; - border: none; - border-radius: 6px; - font-size: 0.9rem; - cursor: pointer; -} - -.openFormButton:hover:not(:disabled) { - opacity: 0.9; -} - -.openFormButton:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.popupSubmitButton { - padding: 0.5rem 1.25rem; - background: var(--success-color, #28a745); - color: white; - border: none; - border-radius: 6px; - font-size: 0.9rem; - cursor: pointer; -} - -.popupSubmitButton:hover:not(:disabled) { - opacity: 0.9; -} - -.popupSubmitButton:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.approvalButtons { - display: flex; - gap: 0.5rem; - margin-top: 0.75rem; -} - -.approvalButtons button, -.taskCard button { - padding: 0.5rem 1rem; - border-radius: 6px; - border: none; - cursor: pointer; - font-size: 0.9rem; -} - -.approvalButtons button:first-child, -.taskCard button[type='button'] { - background: var(--primary-color, #007bff); - color: white; -} - -.approvalButtons button:last-of-type:not(:first-child) { - background: var(--danger-color, #dc3545); - color: white; -} - -.approvalButtons button:disabled, -.taskCard button:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -/* Override broad .taskCard button[type='button'] primary styling for dismiss control */ -.taskCard button.dismissOpenTaskBtn { - background: transparent; - color: var(--text-secondary, #888); - padding: 0; -} - -/* Upload task */ -.uploadTaskBlock { - display: flex; - flex-direction: column; - gap: 0.75rem; - margin-top: 0.5rem; -} - -.uploadTaskBlock .uploadButton { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 1rem; - background: var(--primary-color, #007bff); - color: white; - border: none; - border-radius: 6px; - font-size: 0.9rem; - cursor: pointer; - align-self: flex-start; -} - -.uploadTaskBlock .uploadButton:hover:not(:disabled) { - opacity: 0.9; -} - -.uploadTaskBlock .uploadButton:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.uploadTaskBlock .uploadError { - margin: 0; - font-size: 0.875rem; - color: var(--danger-color, #dc3545); -} - -.uploadTaskBlock .uploadedList { - margin: 0; - padding-left: 1.25rem; - font-size: 0.875rem; - color: var(--text-secondary, #666); -} - -/* Output section */ -.outputContent { - margin-top: 0.5rem; - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.outputContent .metaLabel { - margin-top: 0.25rem; -} - -.outputContent .uploadedList { - margin-top: 0.2rem; -} - -.downloadLink { - color: var(--primary-color, #007bff); - text-decoration: none; -} - -.downloadLink:hover { - text-decoration: underline; -} diff --git a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx b/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx deleted file mode 100644 index a63e162..0000000 --- a/src/pages/views/graphicalEditor/GraphicalEditorWorkflowsTasksPage.tsx +++ /dev/null @@ -1,943 +0,0 @@ -/** - * GraphicalEditorWorkflowsTasksPage - * Tasks only (no workflow grouping). - * Open tasks at top, completed tasks at bottom (expandable, scrollable). - * Each task shows workflow, created, due, step, type, and action. - * Right column: active workflows with manual or form entry point — start via execute (same as Workflows page). - */ -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { Link } from 'react-router-dom'; -import { FaChevronDown, FaChevronRight, FaPlay, FaSpinner, FaTimes, FaUpload } from 'react-icons/fa'; -import { useInstanceId } from '../../../hooks/useCurrentInstance'; -import { useApiRequest } from '../../../hooks/useApi'; -import { - fetchTasks, - cancelPendingTaskStopRun, - completeTask, - fetchCompletedRuns, - fetchWorkflows, - executeGraph, - type Automation2Task, - type Automation2Workflow, - type CompletedRun, -} from '../../../api/workflowApi'; -import { useToast } from '../../../contexts/ToastContext'; -import { Popup } from '../../../components/UiComponents/Popup'; -import { getAcceptStringFromConfig, fileMatchesAccept } from '../../../components/FlowEditor'; -import { useFileOperations } from '../../../hooks/useFiles'; -import styles from './Automation2WorkflowsTasks.module.css'; -import { - WorkflowRuntimeFormFields, - useWorkflowRuntimeFormRequiredOk, - type WorkflowRuntimeFormFieldRow, -} from '../../../components/FlowEditor/workflowRuntime/WorkflowRuntimeFormFields'; - -import { useLanguage } from '../../../providers/language/LanguageContext'; - -function _nodeTypeLabel(nodeType: string, t: (k: string) => string): string { - switch (nodeType) { - case 'input.form': return t('Formular'); - case 'input.approval': return t('Genehmigung'); - case 'input.upload': return t('Upload'); - case 'input.comment': return t('Kommentar'); - case 'input.review': return t('Prüfung'); - case 'input.selection': return t('Auswahl'); - case 'input.confirmation': return t('Bestätigung'); - default: return nodeType; - } -} - -function formatTimestamp(ts?: number): string { - if (ts == null || ts <= 0) return '—'; - const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts); - return d.toLocaleString('de-DE', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); -} - -function getNodeStepLabel(config: Record): string { - const title = config?.title; - if (typeof title === 'string' && title.trim()) return title; - const label = config?.label; - if (typeof label === 'string' && label.trim()) return label; - if (typeof label === 'object' && label != null && 'de' in (label as Record)) { - return (label as Record).de ?? (label as Record).en ?? ''; - } - return ''; -} - -/** Active workflow with at least one enabled manual or form start (same idea as Tasks / editor on-demand). */ -function hasManualOrFormInvocation(wf: Automation2Workflow): boolean { - const invs = wf.invocations || []; - return invs.some( - (i) => i.enabled !== false && (i.kind === 'manual' || i.kind === 'form') - ); -} - -/** - * Primary entry for execute — align with first start node in graph order (backend-driven), - * then fall back to manual / form / api on invocations list. - */ -function getPrimaryEntryPoint(wf: Automation2Workflow) { - const invs = wf.invocations || []; - const nodes = wf.graph?.nodes ?? []; - for (const n of nodes) { - const nodeType = n.type; - if (typeof nodeType === 'string' && nodeType.startsWith('trigger.')) { - const inv = invs.find((i) => i.enabled !== false && i.id === n.id); - if (inv) return inv; - } - } - return ( - invs.find((i) => i.enabled !== false && i.kind === 'manual') || - invs.find((i) => i.enabled !== false && (i.kind === 'form' || i.kind === 'api')) - ); -} - -/** Form field rows from graph trigger.form for workflow list (parameters.formFields). */ -function getTriggerFormFieldsForWorkflow(wf: Automation2Workflow): WorkflowRuntimeFormFieldRow[] { - const primary = getPrimaryEntryPoint(wf); - if (!primary || primary.kind !== 'form') return []; - const nodes = wf.graph?.nodes ?? []; - let node = nodes.find((n) => n.id === primary.id && n.type === 'trigger.form'); - if (!node) node = nodes.find((n) => n.type === 'trigger.form'); - if (!node) return []; - const raw = (node.parameters as Record | undefined)?.formFields; - if (!Array.isArray(raw)) return []; - return raw as WorkflowRuntimeFormFieldRow[]; -} - -function primaryKindLabel(kind: string): string { - if (kind === 'form') return 'Formular'; - if (kind === 'manual') return 'Manuell'; - return kind; -} - -export const GraphicalEditorWorkflowsTasksPage: React.FC = () => { - const { t } = useLanguage(); - - const instanceId = useInstanceId(); - const { request } = useApiRequest(); - const { showSuccess, showError } = useToast(); - const [tasks, setTasks] = useState([]); - const [completedRuns, setCompletedRuns] = useState([]); - const [startableWorkflows, setStartableWorkflows] = useState([]); - const [loading, setLoading] = useState(true); - const [completedExpanded, setCompletedExpanded] = useState(false); - const [outputExpanded, setOutputExpanded] = useState(true); - const [submitting, setSubmitting] = useState(null); - const [dismissingTaskId, setDismissingTaskId] = useState(null); - const [executingWorkflowId, setExecutingWorkflowId] = useState(null); - const [formStartWorkflow, setFormStartWorkflow] = useState(null); - const [formStartFields, setFormStartFields] = useState([]); - const [startFormData, setStartFormData] = useState>({}); - - const load = useCallback(async () => { - if (!instanceId) return; - setLoading(true); - try { - const [taskList, runs] = await Promise.all([ - fetchTasks(request, instanceId), - fetchCompletedRuns(request, instanceId, 20), - ]); - setTasks(taskList); - setCompletedRuns(runs); - try { - const activeWfs = await fetchWorkflows(request, instanceId, { active: true }); - const list: Automation2Workflow[] = Array.isArray(activeWfs) - ? activeWfs - : (activeWfs && typeof activeWfs === 'object' && 'items' in activeWfs && Array.isArray((activeWfs as { items: Automation2Workflow[] }).items) - ? (activeWfs as { items: Automation2Workflow[] }).items - : []); - setStartableWorkflows( - list.filter( - (w) => w.active !== false && hasManualOrFormInvocation(w) - ) - ); - } catch (we) { - console.error('[graphicalEditor] load startable workflows failed', we); - setStartableWorkflows([]); - } - } catch (e) { - console.error('[graphicalEditor] load failed', e); - } finally { - setLoading(false); - } - }, [instanceId, request]); - - useEffect(() => { - load(); - }, [load]); - - const handleComplete = async (taskId: string, result: Record) => { - if (!instanceId) return; - setSubmitting(taskId); - try { - await completeTask(request, instanceId, taskId, result); - await load(); - } catch (e) { - console.error('[graphicalEditor] complete failed', e); - } finally { - setSubmitting(null); - } - }; - - const handleDismissOpenTask = async (taskId: string) => { - if (!instanceId) return; - setDismissingTaskId(taskId); - try { - const res = await cancelPendingTaskStopRun(request, instanceId, taskId); - if (res.success) { - showSuccess(t('Ausführung abgebrochen')); - await load(); - } else { - showError(t('Abbrechen fehlgeschlagen')); - } - } catch (e: unknown) { - const msg = - (e as { message?: string })?.message ?? t('Abbrechen fehlgeschlagen'); - showError(msg); - console.error('[graphicalEditor] cancel task failed', e); - } finally { - setDismissingTaskId(null); - } - }; - - const handleStartWorkflow = useCallback( - async (wf: Automation2Workflow) => { - if (!instanceId || !wf.graph) return; - const primary = getPrimaryEntryPoint(wf); - if (primary?.kind === 'form') { - setFormStartFields(getTriggerFormFieldsForWorkflow(wf)); - setStartFormData({}); - setFormStartWorkflow(wf); - return; - } - setExecutingWorkflowId(wf.id); - try { - const result = await executeGraph(request, instanceId, wf.graph, wf.id, { - ...(primary ? { entryPointId: primary.id } : {}), - }); - if (result?.success) { - if (result?.paused) { - showSuccess(t('Workflow gestartet und bei Human Task pausiert.')); - } else { - showSuccess(t('Workflow gestartet')); - } - await load(); - } else { - showError(result?.error || t('Ausführung fehlgeschlagen')); - } - } catch (e: unknown) { - const msg = - (e as { message?: string })?.message ?? t('Ausführung fehlgeschlagen'); - showError(msg); - } finally { - setExecutingWorkflowId(null); - } - }, - [instanceId, request, showSuccess, showError, load, t] - ); - - const formStartRequiredOk = useWorkflowRuntimeFormRequiredOk(formStartFields, startFormData); - - const handleFormStartSubmit = useCallback(async () => { - if (!instanceId || !formStartWorkflow?.graph) return; - const wf = formStartWorkflow; - const primary = getPrimaryEntryPoint(wf); - const payload = { ...startFormData }; - setExecutingWorkflowId(wf.id); - try { - const result = await executeGraph(request, instanceId, wf.graph, wf.id, { - ...(primary ? { entryPointId: primary.id } : {}), - payload, - }); - if (result?.success) { - if (result?.paused) { - showSuccess(t('Workflow gestartet und bei Human Task pausiert.')); - } else { - showSuccess(t('Workflow gestartet')); - } - await load(); - } else { - showError(result?.error || t('Ausführung fehlgeschlagen')); - } - } catch (e: unknown) { - const msg = - (e as { message?: string })?.message ?? t('Ausführung fehlgeschlagen'); - showError(msg); - } finally { - setExecutingWorkflowId(null); - setFormStartWorkflow(null); - } - }, [ - instanceId, - formStartWorkflow, - startFormData, - request, - showSuccess, - showError, - load, - t, - ]); - - const openTasks = tasks.filter((task) => task.status === 'pending'); - const completedTasks = tasks.filter((task) => task.status !== 'pending'); - - if (!instanceId) { - return ( -
-

{t('keine Featureinstanz gefunden')}

-
- ); - } - - if (loading) { - return ( -
- -

{t('lade Tasks')}

-
- ); - } - - return ( -
-
-
- {/* Open tasks */} -
-

- {t('Offene Tasks')} - {openTasks.length > 0 && {openTasks.length}} -

- {openTasks.length === 0 ? ( -

{t('keine offenen Tasks')}

- ) : ( -
- {openTasks.map((task) => ( - handleComplete(task.id, result)} - submitting={submitting === task.id} - showDismiss - onDismiss={() => handleDismissOpenTask(task.id)} - dismissing={dismissingTaskId === task.id} - /> - ))} -
- )} -
- - {/* Completed tasks */} -
- - {completedExpanded && ( -
- {completedTasks.length === 0 ? ( -

{t('keine erledigten Tasks')}

- ) : ( - completedTasks.map((task) => ( - handleComplete(task.id, result)} - submitting={submitting === task.id} - readOnly - /> - )) - )} -
- )} -
- - {/* Output – abgeschlossene Workflows mit Ergebnis */} -
- - {outputExpanded && ( -
- {completedRuns.length === 0 ? ( -

- {t('Keine abgeschlossenen Workflows. Führen Sie einen Workflow aus (z.B. im Editor), um hier die Ergebnisse zu sehen.')} -

- ) : ( - completedRuns.map((run) => ( - - )) - )} -
- )} -
-
-
- - - - setFormStartWorkflow(null)} - closable={ - !(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id) - } - closeOnEscape={ - !(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id) - } - size="medium" - footerContent={ - - } - > - - -
- ); -}; - -/** Output card for completed workflow runs – zeigt nur die erstellten Dateien (mit fileId). */ -const OutputCard: React.FC<{ - run: CompletedRun; - instanceId?: string; -}> = ({ run }) => { - const { t } = useLanguage(); - const ts = run.sysModifiedAt ?? run.sysCreatedAt ?? 0; - const files: Array<{ name: string; fileId: string }> = []; - const nodeOutputs = run.nodeOutputs ?? {}; - for (const [, out] of Object.entries(nodeOutputs)) { - if (!out || typeof out !== 'object') continue; - const o = out as Record; - const docs = (o.documents ?? o.documentList ?? []) as Array>; - if (!Array.isArray(docs)) continue; - for (const d of docs) { - const fileId = (d.validationMetadata as Record)?.fileId as string | undefined; - if (fileId) { - files.push({ - name: String(d.documentName ?? d.fileName ?? t('Datei')), - fileId, - }); - } - } - } - return ( -
-
-
- {t('Workflow')} - {run.workflowLabel || run.workflowId || '—'} -
-
- {t('Abgeschlossen')} - {formatTimestamp(ts)} -
-
- {files.length > 0 ? ( -
- {t('Dateien')} -
    - {files.map((f, j) => ( -
  • - - {f.name} - -
  • - ))} -
-
- ) : ( -

{t('kein Output, z.B. Workflow ohne')}

- )} -
- ); -}; - -interface TaskCardProps { - task: Automation2Task; - instanceId?: string; - onSubmit: (result: Record) => void; - submitting: boolean; - readOnly?: boolean; - /** Open-task card: show top-right control to cancel run and remove from list. */ - showDismiss?: boolean; - onDismiss?: () => void; - dismissing?: boolean; -} - -const TaskCard: React.FC = ({ - task, - instanceId, - onSubmit, - submitting, - readOnly = false, - showDismiss = false, - onDismiss, - dismissing = false, -}) => { - const { t } = useLanguage(); - const { handleFileUpload } = useFileOperations(); - const [formData, setFormData] = useState>({}); - const [formPopupOpen, setFormPopupOpen] = useState(false); - const [uploadedFiles, setUploadedFiles] = useState }>>([]); - const [uploading, setUploading] = useState(false); - const [uploadError, setUploadError] = useState(null); - const fileInputRef = useRef(null); - const config = task.config ?? {}; - const nodeType = task.nodeType; - const stepLabel = getNodeStepLabel(config); - - const inputFormFields: WorkflowRuntimeFormFieldRow[] = - nodeType === 'input.form' - ? ((config.fields as WorkflowRuntimeFormFieldRow[]) ?? []) - : []; - const inputFormRequiredOk = useWorkflowRuntimeFormRequiredOk(inputFormFields, formData); - - useEffect(() => { - setUploadedFiles([]); - setUploadError(null); - }, [task.id]); - - const renderInput = () => { - if (readOnly) return null; - switch (nodeType) { - case 'input.form': { - const formContent = ( - - ); - return ( - <> - - setFormPopupOpen(false)} - size="medium" - footerContent={ - - } - > - {formContent} - - - ); - } - case 'input.approval': - return ( -
- {config.title != null && String(config.title) !== '' &&

{String(config.title)}

} - {config.description != null && String(config.description) !== '' &&

{String(config.description)}

} -
- - -
-
- ); - case 'input.comment': - return ( -
-