plana+c implemented

This commit is contained in:
ValueOn AG 2026-04-29 21:27:15 +02:00
parent 28951a7d22
commit 8cecf3b320
10 changed files with 635 additions and 52 deletions

View file

@ -144,6 +144,8 @@ export interface Automation2Workflow {
label: string; label: string;
graph: Automation2Graph; graph: Automation2Graph;
active?: boolean; active?: boolean;
/** Target feature instance for execution data scope (NULL for templates) */
targetFeatureInstanceId?: string | null;
/** Entry points (Starts) — how this workflow may be invoked */ /** Entry points (Starts) — how this workflow may be invoked */
invocations?: WorkflowEntryPoint[]; invocations?: WorkflowEntryPoint[];
/** Enriched: run count */ /** Enriched: run count */
@ -986,3 +988,87 @@ export async function loadClickupListTasksForDropdown(
acc.sort((a, b) => a.name.localeCompare(b.name, 'de')); acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return acc; return acc;
} }
// ============================================================================
// AUTOMATION WORKSPACE API (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<string, unknown> };
workflow: {
id: string;
label: string;
targetFeatureInstanceId?: string;
featureInstanceId?: string;
tags?: string[];
} | null;
steps: Array<{
id: string;
runId: string;
nodeId: string;
nodeType: string;
status: string;
output?: Record<string, unknown>;
error?: string;
startedAt?: number;
completedAt?: number;
durationMs?: number;
tokensUsed?: number;
}>;
files: Array<{
id: string;
fileName?: string;
contentType?: string;
sizeBytes?: number;
}>;
}
export async function fetchWorkspaceRuns(
request: ApiRequestOptions['request'],
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: ApiRequestOptions['request'],
runId: string,
): Promise<WorkspaceRunDetail> {
const resp = await request({ url: `/api/automations/runs/${runId}/detail`, method: 'GET' });
return resp as WorkspaceRunDetail;
}

View file

@ -59,6 +59,7 @@ import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
import { useFeatureStore } from '../../../stores/featureStore';
const LOG = '[Automation2]'; const LOG = '[Automation2]';
@ -133,6 +134,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null); const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false); const [versionLoading, setVersionLoading] = useState(false);
const [targetFeatureInstanceId, setTargetFeatureInstanceId] = useState<string | null>(instanceId);
const featureStore = useFeatureStore();
const targetInstanceOptions = useMemo(() => {
const allInstances = featureStore.getAllInstances();
return allInstances
.filter((inst) => inst.mandateId === mandateId || !mandateId)
.map((inst) => ({ id: inst.id, label: inst.instanceLabel || inst.featureCode || inst.id }));
}, [featureStore, mandateId]);
const [leftPanelWidth, setLeftPanelWidth] = useState(() => { const [leftPanelWidth, setLeftPanelWidth] = useState(() => {
try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; } try { const v = parseInt(localStorage.getItem('flowEditor.leftPanelWidth') ?? ''); return v >= 240 && v <= 600 ? v : 340; } catch { return 340; }
}); });
@ -297,7 +307,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
setSaving(true); setSaving(true);
try { try {
if (currentWorkflowId) { if (currentWorkflowId) {
await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations }); await updateWorkflow(request, instanceId, currentWorkflowId, { graph, invocations, targetFeatureInstanceId });
setExecuteResult(_buildSaveResult()); setExecuteResult(_buildSaveResult());
} else { } else {
const label = await promptInput(t('Workflow-Name:'), { const label = await promptInput(t('Workflow-Name:'), {
@ -313,6 +323,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
label: label.trim() || t('Neuer Workflow'), label: label.trim() || t('Neuer Workflow'),
graph, graph,
invocations, invocations,
targetFeatureInstanceId,
}); });
setCurrentWorkflowId(created.id); setCurrentWorkflowId(created.id);
if (created.invocations?.length) setInvocations(created.invocations); if (created.invocations?.length) setInvocations(created.invocations);
@ -324,7 +335,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors]); }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput, invocations, t, nodeErrors, targetFeatureInstanceId]);
const handleLoad = useCallback( const handleLoad = useCallback(
async (workflowId: string) => { async (workflowId: string) => {
@ -335,6 +346,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
} else { } else {
applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations); applyGraphWithSync({ nodes: [], connections: [] }, wf.invocations);
} }
setTargetFeatureInstanceId(wf.targetFeatureInstanceId ?? instanceId);
setWorkflows((prev) => { setWorkflows((prev) => {
const idx = prev.findIndex((w) => w.id === workflowId); const idx = prev.findIndex((w) => w.id === workflowId);
if (idx === -1) return [...prev, wf]; if (idx === -1) return [...prev, wf];
@ -661,6 +673,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
[request, instanceId, handleFromApiGraph] [request, instanceId, handleFromApiGraph]
); );
const handleTargetInstanceChange = useCallback(async (newTargetId: string) => {
setTargetFeatureInstanceId(newTargetId || null);
if (currentWorkflowId && newTargetId) {
try {
await updateWorkflow(request, instanceId, currentWorkflowId, { targetFeatureInstanceId: newTargetId });
} catch (e: unknown) {
console.error(`${LOG} target instance update failed`, e);
}
}
}, [request, instanceId, currentWorkflowId]);
const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => { const handleWorkflowRename = useCallback(async (workflowId: string, newName: string) => {
try { try {
await updateWorkflow(request, instanceId, workflowId, { label: newName }); await updateWorkflow(request, instanceId, workflowId, { label: newName });
@ -836,6 +859,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
onAutoLayout={handleAutoLayout} onAutoLayout={handleAutoLayout}
verboseSchema={verboseSchema} verboseSchema={verboseSchema}
onVerboseSchemaChange={setVerboseSchema} onVerboseSchemaChange={setVerboseSchema}
targetFeatureInstanceId={targetFeatureInstanceId}
onTargetInstanceChange={handleTargetInstanceChange}
targetInstanceOptions={targetInstanceOptions}
/> />
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}> <div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>

View file

@ -10,6 +10,11 @@ import styles from './Automation2FlowEditor.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext'; import { useLanguage } from '../../../providers/language/LanguageContext';
import { getUserDataCache } from '../../../utils/userCache'; import { getUserDataCache } from '../../../utils/userCache';
interface TargetInstanceOption {
id: string;
label: string;
}
interface CanvasHeaderProps { interface CanvasHeaderProps {
workflows: Automation2Workflow[]; workflows: Automation2Workflow[];
currentWorkflowId: string | null; currentWorkflowId: string | null;
@ -45,6 +50,9 @@ interface CanvasHeaderProps {
* "Schema (Typ-Referenz)" block and per-parameter type-badges. */ * "Schema (Typ-Referenz)" block and per-parameter type-badges. */
verboseSchema?: boolean; verboseSchema?: boolean;
onVerboseSchemaChange?: (next: boolean) => void; onVerboseSchemaChange?: (next: boolean) => void;
targetFeatureInstanceId?: string | null;
onTargetInstanceChange?: (instanceId: string) => void;
targetInstanceOptions?: TargetInstanceOption[];
} }
function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> { function _getStatusBadge(t: (key: string) => string): Record<string, { label: string; color: string }> {
@ -84,6 +92,9 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
onAutoLayout, onAutoLayout,
verboseSchema, verboseSchema,
onVerboseSchemaChange, onVerboseSchemaChange,
targetFeatureInstanceId,
onTargetInstanceChange,
targetInstanceOptions,
}) => { }) => {
const { t } = useLanguage(); const { t } = useLanguage();
const _isSysAdmin = getUserDataCache()?.isSysAdmin === true; const _isSysAdmin = getUserDataCache()?.isSysAdmin === true;
@ -209,6 +220,21 @@ export const CanvasHeader: React.FC<CanvasHeaderProps> = ({ workflows,
<FaCog /> <FaCog />
</button> </button>
)} )}
{targetInstanceOptions && targetInstanceOptions.length > 0 && onTargetInstanceChange && (
<select
className={styles.canvasHeaderWorkflowSelect}
value={targetFeatureInstanceId ?? ''}
onChange={(e) => onTargetInstanceChange(e.target.value)}
aria-label={t('Ziel-Instanz')}
title={t('Ziel-Instanz für Daten-Scope')}
style={{ maxWidth: 200, fontSize: '0.8rem' }}
>
<option value="">{t('Ziel-Instanz wählen…')}</option>
{targetInstanceOptions.map((opt) => (
<option key={opt.id} value={opt.id}>{opt.label}</option>
))}
</select>
)}
</div> </div>
<div className={styles.canvasHeaderActionPanel} role="toolbar" aria-label={t('Workflow-Aktionen')}> <div className={styles.canvasHeaderActionPanel} role="toolbar" aria-label={t('Workflow-Aktionen')}>

View file

@ -0,0 +1,171 @@
/**
* TemplateTextarea Freitext mit eingebetteten {{nodeId.path}} Tokens.
* Tokens werden zur Laufzeit von resolveParameterReferences aufgeloest (Gateway).
*/
import React, { useCallback, useMemo, useRef, useState } from 'react';
import type { FieldRendererProps } from './index';
import { useAutomation2DataFlow } from '../../context/Automation2DataFlowContext';
import { DataPicker } from '../shared/DataPicker';
import { formatRefLabel, isRef, isSystemVar, type DataRef, type SystemVarRef } from '../shared/dataRef';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from '../../editor/Automation2FlowEditor.module.css';
const _TEMPLATE_TOKEN_RE = /\{\{\s*([^}]+?)\s*\}\}/g;
function _refToTemplateToken(ref: DataRef): string {
const pathSegs = (ref.path ?? []).map((p) => String(p));
if (pathSegs.length === 0) {
return `{{${ref.nodeId}}}`;
}
return `{{${ref.nodeId}.${pathSegs.join('.')}}}`;
}
function _insertAtCursor(
text: string,
insert: string,
start: number,
end: number,
): { next: string; caret: number } {
const next = text.slice(0, start) + insert + text.slice(end);
const caret = start + insert.length;
return { next, caret };
}
function _parseTokensInTemplate(
template: string,
nodes: Array<{ id: string; title?: string }>,
getNodeLabel: (n: { id: string; title?: string }) => string,
): Array<{ raw: string; label: string }> {
const out: Array<{ raw: string; label: string }> = [];
const seen = new Set<string>();
let m: RegExpExecArray | null;
const re = new RegExp(_TEMPLATE_TOKEN_RE.source, 'g');
while ((m = re.exec(template)) !== null) {
const inner = m[1].trim();
if (seen.has(inner)) continue;
seen.add(inner);
const parts = inner.split('.');
const nodeId = parts[0];
if (!nodeId) continue;
const path = parts.slice(1).map((seg) => (/^\d+$/.test(seg) ? parseInt(seg, 10) : seg));
const ref: DataRef = { type: 'ref', nodeId, path };
const label = formatRefLabel(ref, nodes, (id) =>
getNodeLabel(nodes.find((n) => n.id === id) ?? { id }),
);
out.push({ raw: m[0], label });
}
return out;
}
export const TemplateTextareaRenderer: React.FC<FieldRendererProps> = ({ param, value, onChange }) => {
const { t } = useLanguage();
const dataFlow = useAutomation2DataFlow();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const strVal = typeof value === 'string' ? value : value != null ? String(value) : '';
const sourceIds = dataFlow?.getAvailableSourceIds() ?? [];
const hasSources = sourceIds.some((id) => {
const n = dataFlow?.nodes.find((x) => x.id === id);
return n?.type !== 'trigger.manual';
});
const tokenLegend = useMemo(() => {
if (!dataFlow || !strVal.includes('{{')) return [];
return _parseTokensInTemplate(strVal, dataFlow.nodes, dataFlow.getNodeLabel);
}, [strVal, dataFlow]);
const handlePick = useCallback(
(picked: DataRef | SystemVarRef) => {
if (isSystemVar(picked)) {
setPickerOpen(false);
return;
}
if (!isRef(picked)) {
setPickerOpen(false);
return;
}
const token = _refToTemplateToken(picked);
const el = textareaRef.current;
const start = el?.selectionStart ?? strVal.length;
const end = el?.selectionEnd ?? strVal.length;
const { next, caret } = _insertAtCursor(strVal, token, start, end);
onChange(next);
setPickerOpen(false);
requestAnimationFrame(() => {
const ta = textareaRef.current;
if (ta) {
ta.focus();
ta.setSelectionRange(caret, caret);
}
});
},
[onChange, strVal],
);
return (
<div style={{ marginBottom: 8 }}>
<label style={{ display: 'block', fontSize: 12, marginBottom: 2 }}>{param.description || param.name}</label>
<div style={{ display: 'flex', gap: 6, marginBottom: 4, flexWrap: 'wrap', alignItems: 'center' }}>
<button
type="button"
className={styles.startsInput}
disabled={!hasSources}
onClick={() => setPickerOpen(true)}
title={hasSources ? t('Variable aus vorherigem Node einfügen') : t('Keine vorherigen Nodes verfügbar')}
>
{t('Variable einfügen…')}
</button>
{!hasSources && (
<span style={{ fontSize: 11, color: 'var(--text-secondary)' }}>{t('Keine vorherigen Nodes verfügbar')}</span>
)}
</div>
<textarea
ref={textareaRef}
value={strVal}
onChange={(e) => onChange(e.target.value)}
placeholder={param.name}
rows={6}
spellCheck={false}
style={{
width: '100%',
padding: '6px 8px',
borderRadius: 4,
border: '1px solid #ccc',
resize: 'vertical',
fontFamily: 'ui-monospace, monospace',
fontSize: 12,
minHeight: 120,
}}
/>
{tokenLegend.length > 0 && (
<div style={{ marginTop: 6, fontSize: 11, color: 'var(--text-secondary)' }}>
<div style={{ fontWeight: 600, marginBottom: 2 }}>{t('Eingebundene Variablen')}</div>
<ul style={{ margin: 0, paddingLeft: 18 }}>
{tokenLegend.map((row) => (
<li key={row.raw} style={{ marginBottom: 2 }}>
<code style={{ fontSize: 10 }}>{row.raw}</code>
{' — '}
{row.label}
</li>
))}
</ul>
</div>
)}
{dataFlow && (
<DataPicker
open={pickerOpen}
onClose={() => setPickerOpen(false)}
onPick={handlePick}
availableSourceIds={sourceIds}
nodes={dataFlow.nodes}
nodeOutputsPreview={dataFlow.nodeOutputsPreview}
getNodeLabel={dataFlow.getNodeLabel}
expectedParamType={param.type}
/>
)}
</div>
);
};

View file

@ -33,6 +33,7 @@ import { postUpstreamPaths } from '../../../../api/workflowApi';
import type { CanvasNode } from '../../editor/FlowCanvas'; import type { CanvasNode } from '../../editor/FlowCanvas';
import { DataRefRenderer } from './DataRefRenderer'; import { DataRefRenderer } from './DataRefRenderer';
import { FeatureInstancePicker } from './FeatureInstancePicker'; import { FeatureInstancePicker } from './FeatureInstancePicker';
import { TemplateTextareaRenderer } from './TemplateTextareaRenderer';
const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => ( const TextInput: React.FC<FieldRendererProps> = ({ param, value, onChange }) => (
<div style={{ marginBottom: 8 }}> <div style={{ marginBottom: 8 }}>
@ -743,6 +744,7 @@ const FilterExpressionEditor: React.FC<FieldRendererProps> = ({ param, value, on
export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = { export const FRONTEND_TYPE_RENDERERS: Record<string, FieldRendererComponent> = {
text: TextInput, text: TextInput,
textarea: TextareaInput, textarea: TextareaInput,
templateTextarea: TemplateTextareaRenderer,
number: NumberInput, number: NumberInput,
checkbox: CheckboxInput, checkbox: CheckboxInput,
date: DateInput, date: DateInput,

View file

@ -91,6 +91,37 @@ function buildFormSchemaPayloadPaths(params: Record<string, unknown>): Array<{
return out; return out;
} }
function buildLoopCurrentItemPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
const paths: Array<{ path: (string | number)[]; pathLabel: string }> = [
{ path: ['currentItem'], pathLabel: 'currentItem' },
{ path: ['currentIndex'], pathLabel: 'currentIndex' },
{ path: ['count'], pathLabel: 'count' },
];
if (preview && typeof preview === 'object') {
const ci = (preview as Record<string, unknown>).currentItem;
if (ci && typeof ci === 'object' && !Array.isArray(ci)) {
for (const [k, v] of Object.entries(ci as Record<string, unknown>)) {
paths.push(...buildPickablePaths(v, ['currentItem', k]));
}
}
}
return paths;
}
function buildAiPromptPaths(preview: unknown): Array<{ path: (string | number)[]; pathLabel: string }> {
const paths = buildPickablePaths(preview);
if (preview && typeof preview === 'object') {
const rd = (preview as Record<string, unknown>).responseData;
if (rd && typeof rd === 'object' && !Array.isArray(rd)) {
for (const k of Object.keys(rd as Record<string, unknown>)) {
const p = { path: ['responseData', k], pathLabel: `responseData.${k}` };
if (!paths.some((x) => x.pathLabel === p.pathLabel)) paths.push(p);
}
}
}
return paths;
}
export function pickPathsForNode( export function pickPathsForNode(
node: { type?: string; parameters?: Record<string, unknown> } | undefined, node: { type?: string; parameters?: Record<string, unknown> } | undefined,
preview: unknown, preview: unknown,
@ -113,6 +144,12 @@ export function pickPathsForNode(
if (nt.startsWith('clickup.')) { if (nt.startsWith('clickup.')) {
return buildClickUpOutputPaths(preview); return buildClickUpOutputPaths(preview);
} }
if (nt === 'flow.loop') {
return buildLoopCurrentItemPaths(preview);
}
if (nt === 'ai.prompt') {
return buildAiPromptPaths(preview);
}
return buildPickablePaths(preview); return buildPickablePaths(preview);
} }

View file

@ -76,6 +76,32 @@ export function buildNodeOutputPreview(
return _buildSchemaPreview(port0.schema); return _buildSchemaPreview(port0.schema);
} }
function _buildEmailItemPreview(): Record<string, unknown> {
return {
from: { emailAddress: { address: 'sender@example.com', name: 'Sender' } },
subject: '...',
body: { contentType: 'HTML', content: '...' },
receivedDateTime: '2026-01-01T00:00:00Z',
toRecipients: [],
hasAttachments: false,
id: '...',
};
}
function _buildAiResponseDataPreview(params: Record<string, unknown>): Record<string, unknown> | null {
if (params.resultType !== 'json') return null;
const prompt = String(params.aiPrompt || params.prompt || '');
if (!prompt) return null;
const fields: Record<string, unknown> = {};
const re = /["']?(\w+)["']?\s*:\s*(?:true|false|"[^"]*"|'[^']*'|\d+|boolean|string|number|bool)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(prompt)) !== null) {
const f = m[1];
if (f && !['type', 'value', 'key'].includes(f)) fields[f] = '...';
}
return Object.keys(fields).length > 0 ? fields : null;
}
/** Build full nodeOutputsPreview map from graph */ /** Build full nodeOutputsPreview map from graph */
export function buildNodeOutputsPreview( export function buildNodeOutputsPreview(
nodes: CanvasNode[], nodes: CanvasNode[],
@ -92,5 +118,32 @@ export function buildNodeOutputsPreview(
result[n.id] = buildNodeOutputPreview(n, typeMap.get(n.type)); result[n.id] = buildNodeOutputPreview(n, typeMap.get(n.type));
} }
} }
for (const n of nodes) {
if (n.id in (nodeOutputsFromRun ?? {})) continue;
if (n.type === 'flow.loop') {
const items = n.parameters?.items;
if (items && typeof items === 'object' && (items as { type?: string }).type === 'ref') {
const ref = items as { nodeId: string; path?: (string | number)[] };
const sourceNode = nodes.find((sn) => sn.id === ref.nodeId);
const sourceDef = sourceNode ? typeMap.get(sourceNode.type) : undefined;
const sourceSchema = sourceDef?.outputPorts?.[0]?.schema;
if (sourceSchema === 'EmailList') {
const existing = (result[n.id] ?? {}) as Record<string, unknown>;
result[n.id] = { ...existing, currentItem: _buildEmailItemPreview() };
}
}
}
if (n.type === 'ai.prompt' && n.parameters) {
const rdPreview = _buildAiResponseDataPreview(n.parameters);
if (rdPreview) {
const existing = (result[n.id] ?? {}) as Record<string, unknown>;
result[n.id] = { ...existing, responseData: rdPreview };
}
}
}
return result; return result;
} }

View file

@ -15,7 +15,7 @@ import { useToast } from '../contexts/ToastContext';
import { usePrompt } from '../hooks/usePrompt'; import { usePrompt } from '../hooks/usePrompt';
import { useApiRequest } from '../hooks/useApi'; import { useApiRequest } from '../hooks/useApi';
import { formatUnixTimestamp } from '../utils/time'; import { formatUnixTimestamp } from '../utils/time';
import { updateWorkflow, executeGraph, deleteSystemWorkflow } from '../api/workflowApi'; import { updateWorkflow, executeGraph, deleteSystemWorkflow, fetchWorkspaceRuns, fetchWorkspaceRunDetail, type WorkspaceRun } from '../api/workflowApi';
import { fetchAttributes } from '../api/attributesApi'; import { fetchAttributes } from '../api/attributesApi';
import type { AttributeDefinition } from '../api/attributesApi'; import type { AttributeDefinition } from '../api/attributesApi';
import { resolveColumnTypes } from '../utils/columnTypeResolver'; import { resolveColumnTypes } from '../utils/columnTypeResolver';
@ -1060,6 +1060,186 @@ const _WorkflowsTab: React.FC = () => {
); );
}; };
// ===========================================================================
// Workspace Tab (user-facing workflow run history)
// ===========================================================================
const _WorkspaceTab: React.FC = () => {
const { t } = useLanguage();
const { request } = useApiRequest();
const navigate = useNavigate();
const [runs, setRuns] = useState<WorkspaceRun[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [scope, setScope] = useState<'mine' | 'mandate'>('mine');
const [statusFilter, setStatusFilter] = useState<string>('');
const [selectedRunId, setSelectedRunId] = useState<string | null>(null);
const [runDetail, setRunDetail] = useState<Awaited<ReturnType<typeof fetchWorkspaceRunDetail>> | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const _loadRuns = useCallback(async () => {
setLoading(true);
try {
const data = await fetchWorkspaceRuns(request, {
scope,
status: statusFilter || undefined,
limit: 50,
});
setRuns(data.runs || []);
setTotal(data.total || 0);
} catch (e) {
console.error('Workspace runs load failed', e);
} finally {
setLoading(false);
}
}, [request, scope, statusFilter]);
useEffect(() => { _loadRuns(); }, [_loadRuns]);
const _loadDetail = useCallback(async (runId: string) => {
setDetailLoading(true);
try {
const detail = await fetchWorkspaceRunDetail(request, runId);
setRunDetail(detail);
} catch (e) {
console.error('Workspace run detail failed', e);
} finally {
setDetailLoading(false);
}
}, [request]);
useEffect(() => {
if (selectedRunId) _loadDetail(selectedRunId);
else setRunDetail(null);
}, [selectedRunId, _loadDetail]);
if (selectedRunId && runDetail) {
const { run, steps, files, workflow } = runDetail;
return (
<div style={{ padding: '1rem' }}>
<button type="button" className="btn-link" onClick={() => setSelectedRunId(null)} style={{ marginBottom: '1rem' }}>
{t('Zurück zur Liste')}
</button>
<h3 style={{ margin: '0.5rem 0' }}>{run.workflowLabel || run.workflowId}</h3>
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', fontSize: '0.85rem', color: 'var(--text-secondary)', marginBottom: '1rem' }}>
<span><strong>{t('Status')}:</strong> {run.status}</span>
{run.startedAt && <span><strong>{t('Start')}:</strong> {formatUnixTimestamp(run.startedAt)}</span>}
{run.completedAt && <span><strong>{t('Ende')}:</strong> {formatUnixTimestamp(run.completedAt)}</span>}
{workflow?.targetFeatureInstanceId && <span><strong>{t('Ziel-Instanz')}:</strong> {run.targetInstanceLabel || workflow.targetFeatureInstanceId}</span>}
{(run.costTokens ?? 0) > 0 && <span><strong>Tokens:</strong> {run.costTokens}</span>}
</div>
{run.error && (
<div style={{ padding: '0.5rem', background: 'rgba(220,53,69,0.1)', borderRadius: 6, marginBottom: '1rem', color: 'var(--danger-color)' }}>
{run.error}
</div>
)}
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Schritte')}</h4>
{steps.length === 0 ? (
<p style={{ color: 'var(--text-secondary)' }}>{t('Keine Schritte protokolliert.')}</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{steps.map((step) => (
<details key={step.id} style={{ border: '1px solid var(--border-color)', borderRadius: 6, padding: '0.5rem' }}>
<summary style={{ cursor: 'pointer', fontWeight: 500 }}>
<span style={{ marginRight: '0.5rem', fontSize: '0.75rem', padding: '2px 6px', borderRadius: 4, background: step.status === 'completed' ? 'rgba(40,167,69,0.15)' : step.status === 'failed' ? 'rgba(220,53,69,0.15)' : 'rgba(0,123,255,0.15)', color: step.status === 'completed' ? 'var(--success-color)' : step.status === 'failed' ? 'var(--danger-color)' : 'var(--primary-color)' }}>
{step.status}
</span>
{step.nodeType} ({step.nodeId})
{step.durationMs != null && <span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{step.durationMs}ms</span>}
</summary>
{step.output && Object.keys(step.output).length > 0 && (
<pre style={{ fontSize: '0.75rem', maxHeight: 300, overflow: 'auto', marginTop: '0.5rem', background: 'var(--bg-secondary)', padding: '0.5rem', borderRadius: 4 }}>
{JSON.stringify(step.output, null, 2)}
</pre>
)}
{step.error && <p style={{ color: 'var(--danger-color)', marginTop: '0.25rem' }}>{step.error}</p>}
</details>
))}
</div>
)}
{files.length > 0 && (
<>
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Dokumente')}</h4>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
{files.map((f) => (
<a
key={f.id}
href={`/api/files/${f.id}/download`}
download
style={{ padding: '0.5rem 1rem', border: '1px solid var(--border-color)', borderRadius: 6, textDecoration: 'none', color: 'var(--primary-color)', fontSize: '0.85rem' }}
>
<FaDownload style={{ marginRight: 4 }} />
{f.fileName || f.id}
</a>
))}
</div>
</>
)}
</div>
);
}
return (
<div style={{ padding: '1rem' }}>
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center', marginBottom: '1rem' }}>
<select value={scope} onChange={(e) => setScope(e.target.value as 'mine' | 'mandate')} style={{ padding: '0.3rem 0.5rem' }}>
<option value="mine">{t('Meine Runs')}</option>
<option value="mandate">{t('Alle zugänglichen')}</option>
</select>
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)} style={{ padding: '0.3rem 0.5rem' }}>
<option value="">{t('Alle Status')}</option>
<option value="completed">{t('Abgeschlossen')}</option>
<option value="running">{t('Läuft')}</option>
<option value="failed">{t('Fehlgeschlagen')}</option>
<option value="paused">{t('Pausiert')}</option>
</select>
<button type="button" onClick={_loadRuns} style={{ padding: '0.3rem 0.8rem' }}>
<FaSync style={{ marginRight: 4 }} /> {t('Aktualisieren')}
</button>
<span style={{ fontSize: '0.85rem', color: 'var(--text-secondary)' }}>{total} {t('Runs')}</span>
</div>
{loading ? (
<p>{t('Laden…')}</p>
) : runs.length === 0 ? (
<p style={{ color: 'var(--text-secondary)' }}>{t('Keine Workflow-Runs gefunden.')}</p>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color)', textAlign: 'left' }}>
<th style={{ padding: '0.5rem' }}>{t('Workflow')}</th>
<th style={{ padding: '0.5rem' }}>{t('Status')}</th>
<th style={{ padding: '0.5rem' }}>{t('Gestartet')}</th>
<th style={{ padding: '0.5rem' }}>{t('Ziel-Instanz')}</th>
<th style={{ padding: '0.5rem' }}>Tokens</th>
</tr>
</thead>
<tbody>
{runs.map((run) => (
<tr
key={run.id}
onClick={() => setSelectedRunId(run.id)}
style={{ borderBottom: '1px solid var(--border-color)', cursor: 'pointer' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'var(--bg-hover, rgba(0,0,0,0.03))')}
onMouseLeave={(e) => (e.currentTarget.style.background = '')}
>
<td style={{ padding: '0.5rem' }}>{run.workflowLabel || run.workflowId}</td>
<td style={{ padding: '0.5rem' }}>
<span style={{ padding: '2px 8px', borderRadius: 10, fontSize: '0.75rem', fontWeight: 600, background: run.status === 'completed' ? 'rgba(40,167,69,0.15)' : run.status === 'failed' ? 'rgba(220,53,69,0.15)' : run.status === 'running' ? 'rgba(0,123,255,0.15)' : 'rgba(255,193,7,0.15)', color: run.status === 'completed' ? 'var(--success-color)' : run.status === 'failed' ? 'var(--danger-color)' : run.status === 'running' ? 'var(--primary-color)' : 'var(--warning-color)' }}>
{run.status}
</span>
</td>
<td style={{ padding: '0.5rem' }}>{run.startedAt ? formatUnixTimestamp(run.startedAt) : '—'}</td>
<td style={{ padding: '0.5rem' }}>{run.targetInstanceLabel || '—'}</td>
<td style={{ padding: '0.5rem' }}>{run.costTokens ?? 0}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
// =========================================================================== // ===========================================================================
// Main page with Tabs // Main page with Tabs
// =========================================================================== // ===========================================================================
@ -1078,6 +1258,11 @@ export const AutomationsDashboardPage: React.FC = () => {
label: t('Workflows'), label: t('Workflows'),
content: <_WorkflowsTab />, content: <_WorkflowsTab />,
}, },
{
id: 'workspace',
label: t('Workspace'),
content: <_WorkspaceTab />,
},
], [t]); ], [t]);
return ( return (

View file

@ -8,7 +8,7 @@
*/ */
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api'; import api from '../../../api';
@ -76,6 +76,7 @@ type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
export const TrusteeAbschlussView: React.FC = () => { export const TrusteeAbschlussView: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const navigate = useNavigate();
const { instanceId } = useCurrentInstance(); const { instanceId } = useCurrentInstance();
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -325,6 +326,25 @@ export const TrusteeAbschlussView: React.FC = () => {
{runState === 'error' && t('Fehler')} {runState === 'error' && t('Fehler')}
{runSummary && <div style={{ marginTop: '0.25rem' }}>{runSummary}</div>} {runSummary && <div style={{ marginTop: '0.25rem' }}>{runSummary}</div>}
{runError && <div style={{ marginTop: '0.25rem' }}>{runError}</div>} {runError && <div style={{ marginTop: '0.25rem' }}>{runError}</div>}
{runState === 'completed' && runId && (
<div style={{ marginTop: '0.5rem' }}>
<a
href={`/automations?tab=workspace&runId=${runId}`}
onClick={(e) => {
e.preventDefault();
navigate(`/automations?tab=workspace&runId=${runId}`);
}}
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem', borderRadius: '6px',
background: 'var(--primary-color, #007bff)', color: '#fff',
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
}}
>
{t('Im Workspace ansehen')}
</a>
</div>
)}
</div> </div>
)} )}
</div> </div>

View file

@ -8,7 +8,7 @@
*/ */
import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useToast } from '../../../contexts/ToastContext'; import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api'; import api from '../../../api';
@ -102,6 +102,7 @@ type RunState = 'idle' | 'starting' | 'running' | 'completed' | 'error';
export const TrusteeAnalyseView: React.FC = () => { export const TrusteeAnalyseView: React.FC = () => {
const { t } = useLanguage(); const { t } = useLanguage();
const navigate = useNavigate();
const { instanceId } = useCurrentInstance(); const { instanceId } = useCurrentInstance();
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
@ -481,58 +482,34 @@ export const TrusteeAnalyseView: React.FC = () => {
</div> </div>
)} )}
{/* Results */} {/* Workspace link (replaces inline results) */}
{runState === 'completed' && (resultText || resultDocuments.length > 0) && ( {runState === 'completed' && runId && (
<div style={{ <div style={{
marginTop: '0.5rem', marginTop: '0.5rem',
padding: '0.75rem 1rem',
border: '1px solid var(--border-color, #e0e0e0)', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: '8px', borderRadius: '8px',
overflow: 'hidden', background: 'rgba(40,167,69,0.08)',
display: 'flex', alignItems: 'center', gap: '0.75rem',
}}> }}>
{resultDocuments.length > 0 && ( <span style={{ fontSize: '0.875rem', color: 'var(--success-color, #28a745)' }}>
<div style={{ {t('Workflow abgeschlossen.')}
padding: '0.75rem 1rem', </span>
background: 'var(--bg-secondary, #f9f9f9)', <a
borderBottom: resultText ? '1px solid var(--border-color, #e0e0e0)' : 'none', href={`/automations?tab=workspace&runId=${runId}`}
display: 'flex', flexWrap: 'wrap', gap: '0.5rem', alignItems: 'center', onClick={(e) => {
}}> e.preventDefault();
<strong style={{ fontSize: '0.8125rem' }}>{t('Generierte Dokumente:')}</strong> navigate(`/automations?tab=workspace&runId=${runId}`);
{resultDocuments.map((doc, idx) => { }}
const docId = doc.id || (typeof doc === 'string' ? doc : null); style={{
const docName = doc.fileName || `Dokument ${idx + 1}`; display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
if (!docId) return null; padding: '0.375rem 0.75rem', borderRadius: '6px',
return ( background: 'var(--primary-color, #007bff)', color: '#fff',
<a fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
key={docId} }}
href={`${api.defaults.baseURL || ''}/api/files/${docId}/download`} >
target="_blank" {t('Im Workspace ansehen')}
rel="noopener noreferrer" </a>
style={{
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
padding: '0.375rem 0.75rem', borderRadius: '6px',
background: 'var(--primary-color, #007bff)', color: '#fff',
fontSize: '0.8125rem', textDecoration: 'none', fontWeight: 500,
}}
>
📄 {docName}
</a>
);
})}
</div>
)}
{resultText && (
<div style={{
padding: '1rem',
fontSize: '0.875rem',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
maxHeight: '400px',
overflowY: 'auto',
background: 'var(--bg-primary, #fff)',
}}>
{resultText}
</div>
)}
</div> </div>
)} )}
</div> </div>