plana+c implemented
This commit is contained in:
parent
28951a7d22
commit
8cecf3b320
10 changed files with 635 additions and 52 deletions
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }}>
|
||||||
|
|
|
||||||
|
|
@ -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')}>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue