refactory workflowAutomation completed as system component reolacing automation2 and graphEditor
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 53s
Some checks failed
Deploy Nyla Frontend to Integration / deploy (push) Failing after 53s
This commit is contained in:
parent
d398907edc
commit
cd14babb2e
40 changed files with 3422 additions and 4342 deletions
|
|
@ -43,8 +43,7 @@ import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandat
|
||||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||||
import { AutomationsDashboardPage } from './pages/AutomationsDashboardPage';
|
import { WorkflowAutomationPage } from './pages/workflowAutomation/WorkflowAutomationHubPage';
|
||||||
import { WorkflowAutomationPage } from './pages/WorkflowAutomationPage';
|
|
||||||
import { RagInventoryPage } from './pages/RagInventoryPage';
|
import { RagInventoryPage } from './pages/RagInventoryPage';
|
||||||
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
|
import { ComplianceAuditPage } from './pages/ComplianceAuditPage';
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -124,11 +123,6 @@ function App() {
|
||||||
<Route path="admin" element={<BillingAdmin />} />
|
<Route path="admin" element={<BillingAdmin />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* ============================================== */}
|
|
||||||
{/* AUTOMATIONS DASHBOARD */}
|
|
||||||
{/* ============================================== */}
|
|
||||||
<Route path="automations" element={<AutomationsDashboardPage />} />
|
|
||||||
|
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
{/* WORKFLOW AUTOMATION (System-Komponente) */}
|
{/* WORKFLOW AUTOMATION (System-Komponente) */}
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
|
|
@ -143,6 +137,7 @@ function App() {
|
||||||
<Route path="pek" element={<Navigate to="/" replace />} />
|
<Route path="pek" element={<Navigate to="/" replace />} />
|
||||||
<Route path="speech" element={<Navigate to="/" replace />} />
|
<Route path="speech" element={<Navigate to="/" replace />} />
|
||||||
|
|
||||||
|
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
{/* FEATURE-INSTANZ ROUTES */}
|
{/* FEATURE-INSTANZ ROUTES */}
|
||||||
{/* /mandates/:mandateId/:featureCode/:instanceId */}
|
{/* /mandates/:mandateId/:featureCode/:instanceId */}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
1193
src/api/workflowAutomationApi.ts
Normal file
1193
src/api/workflowAutomationApi.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -23,6 +23,7 @@ import {
|
||||||
createTemplateFromWorkflow,
|
createTemplateFromWorkflow,
|
||||||
copyTemplate,
|
copyTemplate,
|
||||||
importWorkflowFromFile,
|
importWorkflowFromFile,
|
||||||
|
WORKFLOW_FILE_EXTENSION,
|
||||||
type NodeType,
|
type NodeType,
|
||||||
type NodeTypeCategory,
|
type NodeTypeCategory,
|
||||||
type Automation2Graph,
|
type Automation2Graph,
|
||||||
|
|
@ -153,7 +154,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
instanceId,
|
instanceId,
|
||||||
mandateId: mandateId || '',
|
mandateId: mandateId || '',
|
||||||
featureInstanceId: instanceId,
|
featureInstanceId: instanceId,
|
||||||
surface: 'graphEditor',
|
surface: 'workflowAutomation',
|
||||||
}), [instanceId, mandateId]);
|
}), [instanceId, mandateId]);
|
||||||
const [versions, setVersions] = useState<AutoVersion[]>([]);
|
const [versions, setVersions] = useState<AutoVersion[]>([]);
|
||||||
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
|
||||||
|
|
@ -354,7 +355,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
try {
|
try {
|
||||||
const ep = currentWorkflowId ? invocations[0]?.id : undefined;
|
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 } : {}),
|
...(ep ? { entryPointId: ep } : {}),
|
||||||
});
|
});
|
||||||
setExecuteResult(result);
|
setExecuteResult(result);
|
||||||
|
|
@ -403,7 +404,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
if (currentWorkflowId) {
|
if (currentWorkflowId) {
|
||||||
const updated = await updateWorkflow(request, instanceId, currentWorkflowId, {
|
const updated = await updateWorkflow(request, currentWorkflowId, {
|
||||||
graph,
|
graph,
|
||||||
invocations,
|
invocations,
|
||||||
targetFeatureInstanceId,
|
targetFeatureInstanceId,
|
||||||
|
|
@ -420,11 +421,12 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const created = await createWorkflow(request, instanceId, {
|
const created = await createWorkflow(request, {
|
||||||
label: label.trim() || t('Neuer Workflow'),
|
label: label.trim() || t('Neuer Workflow'),
|
||||||
graph,
|
graph,
|
||||||
invocations,
|
invocations,
|
||||||
targetFeatureInstanceId,
|
targetFeatureInstanceId,
|
||||||
|
mandateId,
|
||||||
});
|
});
|
||||||
setCurrentWorkflowId(created.id);
|
setCurrentWorkflowId(created.id);
|
||||||
setInvocations(created.invocations ?? []);
|
setInvocations(created.invocations ?? []);
|
||||||
|
|
@ -436,12 +438,12 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
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(
|
const handleLoad = useCallback(
|
||||||
async (workflowId: string) => {
|
async (workflowId: string) => {
|
||||||
try {
|
try {
|
||||||
const wf = await fetchWorkflow(request, instanceId, workflowId);
|
const wf = await fetchWorkflow(request, workflowId);
|
||||||
if (wf.graph) {
|
if (wf.graph) {
|
||||||
handleFromApiGraph(wf.graph, wf.invocations);
|
handleFromApiGraph(wf.graph, wf.invocations);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -463,7 +465,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setExecuteResult(null);
|
setExecuteResult(null);
|
||||||
applyGraphWithSync({ nodes: [], connections: [] }, []);
|
applyGraphWithSync({ nodes: [], connections: [] }, []);
|
||||||
try {
|
try {
|
||||||
const result = await fetchWorkflows(request, instanceId);
|
const result = await fetchWorkflows(request);
|
||||||
setWorkflows(Array.isArray(result) ? result : result.items);
|
setWorkflows(Array.isArray(result) ? result : result.items);
|
||||||
} catch (refreshErr) {
|
} catch (refreshErr) {
|
||||||
console.error(`${LOG} workflows refresh failed`, refreshErr);
|
console.error(`${LOG} workflows refresh failed`, refreshErr);
|
||||||
|
|
@ -476,7 +478,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, instanceId, handleFromApiGraph, applyGraphWithSync, t]
|
[request, handleFromApiGraph, applyGraphWithSync, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleWorkflowSelect = useCallback(
|
const handleWorkflowSelect = useCallback(
|
||||||
|
|
@ -544,11 +546,10 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadNodeTypes = useCallback(async () => {
|
const loadNodeTypes = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const data = await fetchNodeTypes(request, instanceId, language);
|
const data = await fetchNodeTypes(request, language);
|
||||||
setNodeTypes(data.nodeTypes);
|
setNodeTypes(data.nodeTypes);
|
||||||
setCategories(data.categories);
|
setCategories(data.categories);
|
||||||
if (data.portTypeCatalog) {
|
if (data.portTypeCatalog) {
|
||||||
|
|
@ -565,17 +566,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [instanceId, language, request]);
|
}, [language, request]);
|
||||||
|
|
||||||
const loadWorkflows = useCallback(async () => {
|
const loadWorkflows = useCallback(async () => {
|
||||||
if (!instanceId) return;
|
|
||||||
try {
|
try {
|
||||||
const result = await fetchWorkflows(request, instanceId);
|
const result = await fetchWorkflows(request);
|
||||||
setWorkflows(Array.isArray(result) ? result : result.items);
|
setWorkflows(Array.isArray(result) ? result : result.items);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`${LOG} loadWorkflows failed`, e);
|
console.error(`${LOG} loadWorkflows failed`, e);
|
||||||
}
|
}
|
||||||
}, [instanceId, request]);
|
}, [request]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadNodeTypes();
|
loadNodeTypes();
|
||||||
|
|
@ -665,17 +665,17 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadVersions = useCallback(async () => {
|
const loadVersions = useCallback(async () => {
|
||||||
if (!instanceId || !currentWorkflowId) {
|
if (!currentWorkflowId) {
|
||||||
setVersions([]);
|
setVersions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const v = await fetchVersions(request, instanceId, currentWorkflowId);
|
const v = await fetchVersions(request, currentWorkflowId);
|
||||||
setVersions(v);
|
setVersions(v);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`${LOG} loadVersions failed`, e);
|
console.error(`${LOG} loadVersions failed`, e);
|
||||||
}
|
}
|
||||||
}, [instanceId, currentWorkflowId, request]);
|
}, [currentWorkflowId, request]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadVersions();
|
loadVersions();
|
||||||
|
|
@ -696,10 +696,9 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
|
|
||||||
const handlePublishVersion = useCallback(
|
const handlePublishVersion = useCallback(
|
||||||
async (versionId: string) => {
|
async (versionId: string) => {
|
||||||
if (!instanceId) return;
|
|
||||||
setVersionLoading(true);
|
setVersionLoading(true);
|
||||||
try {
|
try {
|
||||||
await publishVersion(request, instanceId, versionId);
|
await publishVersion(request, versionId);
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
|
@ -707,15 +706,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setVersionLoading(false);
|
setVersionLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, instanceId, loadVersions]
|
[request, loadVersions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUnpublishVersion = useCallback(
|
const handleUnpublishVersion = useCallback(
|
||||||
async (versionId: string) => {
|
async (versionId: string) => {
|
||||||
if (!instanceId) return;
|
|
||||||
setVersionLoading(true);
|
setVersionLoading(true);
|
||||||
try {
|
try {
|
||||||
await unpublishVersion(request, instanceId, versionId);
|
await unpublishVersion(request, versionId);
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
|
@ -723,15 +721,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setVersionLoading(false);
|
setVersionLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, instanceId, loadVersions]
|
[request, loadVersions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleArchiveVersion = useCallback(
|
const handleArchiveVersion = useCallback(
|
||||||
async (versionId: string) => {
|
async (versionId: string) => {
|
||||||
if (!instanceId) return;
|
|
||||||
setVersionLoading(true);
|
setVersionLoading(true);
|
||||||
try {
|
try {
|
||||||
await archiveVersion(request, instanceId, versionId);
|
await archiveVersion(request, versionId);
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
|
@ -739,14 +736,14 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setVersionLoading(false);
|
setVersionLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, instanceId, loadVersions]
|
[request, loadVersions]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCreateDraft = useCallback(async () => {
|
const handleCreateDraft = useCallback(async () => {
|
||||||
if (!instanceId || !currentWorkflowId) return;
|
if (!currentWorkflowId) return;
|
||||||
setVersionLoading(true);
|
setVersionLoading(true);
|
||||||
try {
|
try {
|
||||||
const draft = await createDraftVersion(request, instanceId, currentWorkflowId);
|
const draft = await createDraftVersion(request, currentWorkflowId);
|
||||||
await loadVersions();
|
await loadVersions();
|
||||||
setCurrentVersionId(draft.id);
|
setCurrentVersionId(draft.id);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
|
|
@ -754,16 +751,16 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
} finally {
|
} finally {
|
||||||
setVersionLoading(false);
|
setVersionLoading(false);
|
||||||
}
|
}
|
||||||
}, [request, instanceId, currentWorkflowId, loadVersions]);
|
}, [request, currentWorkflowId, loadVersions]);
|
||||||
|
|
||||||
// Template: save current workflow as template
|
// Template: save current workflow as template
|
||||||
const [templateSaving, setTemplateSaving] = useState(false);
|
const [templateSaving, setTemplateSaving] = useState(false);
|
||||||
const handleSaveAsTemplate = useCallback(
|
const handleSaveAsTemplate = useCallback(
|
||||||
async (scope: AutoTemplateScope) => {
|
async (scope: AutoTemplateScope) => {
|
||||||
if (!instanceId || !currentWorkflowId) return;
|
if (!currentWorkflowId) return;
|
||||||
setTemplateSaving(true);
|
setTemplateSaving(true);
|
||||||
try {
|
try {
|
||||||
await createTemplateFromWorkflow(request, instanceId, currentWorkflowId, scope);
|
await createTemplateFromWorkflow(request, currentWorkflowId, scope);
|
||||||
setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse);
|
setExecuteResult({ success: true, error: undefined } as unknown as ExecuteGraphResponse);
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
||||||
|
|
@ -771,16 +768,15 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setTemplateSaving(false);
|
setTemplateSaving(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[request, instanceId, currentWorkflowId]
|
[request, currentWorkflowId]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Template: new workflow from template
|
// Template: new workflow from template
|
||||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
const [templatePickerOpen, setTemplatePickerOpen] = useState(false);
|
||||||
const handleNewFromTemplate = useCallback(
|
const handleNewFromTemplate = useCallback(
|
||||||
async (templateId: string) => {
|
async (templateId: string) => {
|
||||||
if (!instanceId) return;
|
|
||||||
try {
|
try {
|
||||||
const wf = await copyTemplate(request, instanceId, templateId);
|
const wf = await copyTemplate(request, templateId);
|
||||||
setWorkflows((prev) => [...prev, wf]);
|
setWorkflows((prev) => [...prev, wf]);
|
||||||
setCurrentWorkflowId(wf.id);
|
setCurrentWorkflowId(wf.id);
|
||||||
if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations);
|
if (wf.graph) handleFromApiGraph(wf.graph, wf.invocations);
|
||||||
|
|
@ -789,7 +785,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
|
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<Automation2FlowEditorProps> = ({ in
|
||||||
activeTab={udbTab as UdbTab}
|
activeTab={udbTab as UdbTab}
|
||||||
onTabChange={(tab) => setUdbTab(tab as LeftTab)}
|
onTabChange={(tab) => setUdbTab(tab as LeftTab)}
|
||||||
hideTabs={['chats']}
|
hideTabs={['chats']}
|
||||||
onFileSelect={onFileSelect}
|
onFileSelect={async (fileId, fileName) => {
|
||||||
onSourcesChanged={onSourcesChanged}
|
if (fileName?.toLowerCase().endsWith(WORKFLOW_FILE_EXTENSION)) {
|
||||||
onWorkflowImportedFromFile={async (workflowId) => {
|
try {
|
||||||
await loadWorkflows();
|
const result = await importWorkflowFromFile(request, { fileId });
|
||||||
handleWorkflowSelect(workflowId);
|
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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1024,12 +1028,12 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({ in
|
||||||
stickyNotes={canvasStickyNotes}
|
stickyNotes={canvasStickyNotes}
|
||||||
onStickyNotesChange={setCanvasStickyNotes}
|
onStickyNotesChange={setCanvasStickyNotes}
|
||||||
onExternalDrop={async (mime, payload) => {
|
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 p = payload as { files?: Array<{ id: string }> } | undefined;
|
||||||
const fileId = p?.files?.[0]?.id;
|
const fileId = p?.files?.[0]?.id;
|
||||||
if (!fileId) return false;
|
if (!fileId) return false;
|
||||||
try {
|
try {
|
||||||
const result = await importWorkflowFromFile(request, instanceId, { fileId });
|
const result = await importWorkflowFromFile(request, { fileId });
|
||||||
await loadWorkflows();
|
await loadWorkflows();
|
||||||
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
|
if (result?.workflow?.id) handleWorkflowSelect(result.workflow.id);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
/**
|
/**
|
||||||
* EditorChatPanel
|
* EditorChatPanel
|
||||||
*
|
*
|
||||||
* AI Chat sidebar for the GraphicalEditor.
|
* AI Chat sidebar for the WorkflowAutomation editor.
|
||||||
* Streams responses via SSE (same pattern as Workspace chat).
|
* Streams responses via SSE (same pattern as Workspace chat).
|
||||||
* File & data-source attachment UX mirrors WorkspaceInput:
|
* File & data-source attachment UX mirrors WorkspaceInput:
|
||||||
* - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB
|
* - Files: drag & drop from FilesTab (UDB) onto input area, or click in UDB
|
||||||
|
|
@ -87,7 +87,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
|
|
||||||
// Load persisted chat history from the backend whenever the workflow changes.
|
// Load persisted chat history from the backend whenever the workflow changes.
|
||||||
// The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is
|
// 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.
|
// For an unsaved workflow (workflowId == null) we just clear the panel.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!workflowId) {
|
if (!workflowId) {
|
||||||
|
|
@ -99,7 +99,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
setHistoryLoading(true);
|
setHistoryLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get<PersistedEditorChatResponse>(
|
const res = await api.get<PersistedEditorChatResponse>(
|
||||||
`/api/workflows/${instanceId}/${workflowId}/chat/messages`,
|
`/api/workflow-automation/${workflowId}/chat/messages`,
|
||||||
);
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const persisted = (res.data?.messages || []).map((m): ChatMessage => ({
|
const persisted = (res.data?.messages || []).map((m): ChatMessage => ({
|
||||||
|
|
@ -166,7 +166,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
|
|
||||||
const baseURL = api.defaults.baseURL || '';
|
const baseURL = api.defaults.baseURL || '';
|
||||||
const cleanup = startSseStream({
|
const cleanup = startSseStream({
|
||||||
url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`,
|
url: `${baseURL}/api/workflow-automation/${workflowId}/chat/stream`,
|
||||||
body,
|
body,
|
||||||
handlers: {
|
handlers: {
|
||||||
onChunk: (event) => {
|
onChunk: (event) => {
|
||||||
|
|
@ -227,7 +227,7 @@ export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({ instanceId,
|
||||||
: m));
|
: m));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`);
|
await api.post(`/api/workflow-automation/${workflowId}/chat/stop`);
|
||||||
} catch {
|
} catch {
|
||||||
}
|
}
|
||||||
abortRef.current?.();
|
abortRef.current?.();
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* EditorWorkflowChatList
|
* EditorWorkflowChatList
|
||||||
*
|
*
|
||||||
* UDB "Chats" tab content for the GraphicalEditor: each AutoWorkflow is treated
|
* UDB "Chats" tab content for the WorkflowAutomation editor: each AutoWorkflow
|
||||||
* as one editor chat session. Lists workflows already loaded by the parent
|
* is treated as one editor chat session. Lists workflows already loaded by the
|
||||||
* editor (no extra fetch), supports search and "+ Neu" to start a fresh
|
* parent editor (no extra fetch), supports search and "+ Neu" to start a fresh
|
||||||
* workflow chat. Mirrors the spirit of the Workspace ChatsTab but uses
|
* 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 React, { useMemo, useState } from 'react';
|
||||||
import type { Automation2Workflow } from '../../../api/workflowApi';
|
import type { Automation2Workflow } from '../../../api/workflowApi';
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await request({
|
const data = await request({
|
||||||
url: `/api/workflows/${instanceId}/runs/${runId}/steps`,
|
url: `/api/workflow-automation/runs/${runId}/steps`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
});
|
});
|
||||||
setSteps(data?.steps || []);
|
setSteps(data?.steps || []);
|
||||||
|
|
@ -115,7 +115,7 @@ export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
|
||||||
loadSteps();
|
loadSteps();
|
||||||
|
|
||||||
const baseUrl = api.defaults.baseURL || '';
|
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 });
|
const es = new EventSource(url, { withCredentials: true });
|
||||||
eventSourceRef.current = es;
|
eventSourceRef.current = es;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export const TemplatePicker: React.FC<TemplatePickerProps> = ({
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const scope = activeScope === 'all' ? undefined : activeScope;
|
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);
|
setTemplates(Array.isArray(result) ? result : result.items);
|
||||||
} catch {
|
} catch {
|
||||||
setTemplates([]);
|
setTemplates([]);
|
||||||
|
|
|
||||||
|
|
@ -157,7 +157,7 @@ export const CaseListEditor: React.FC<FieldRendererProps> = ({
|
||||||
|
|
||||||
if (dataFlow?.instanceId && dataFlow.request) {
|
if (dataFlow?.instanceId && dataFlow.request) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchConditionMeta(dataFlow.request, dataFlow.instanceId, {
|
fetchConditionMeta(dataFlow.request, {
|
||||||
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
|
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
|
||||||
nodeId: dataFlow.currentNodeId,
|
nodeId: dataFlow.currentNodeId,
|
||||||
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
|
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export const ConditionEditor: React.FC<FieldRendererProps> = ({
|
||||||
|
|
||||||
if (dataFlow?.instanceId && dataFlow.request) {
|
if (dataFlow?.instanceId && dataFlow.request) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchConditionMeta(dataFlow.request, dataFlow.instanceId, {
|
fetchConditionMeta(dataFlow.request, {
|
||||||
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
|
graph: toApiGraph(dataFlow.nodes, dataFlow.connections),
|
||||||
nodeId: dataFlow.currentNodeId,
|
nodeId: dataFlow.currentNodeId,
|
||||||
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
|
ref: { type: 'ref', nodeId: ref.nodeId, path: ref.path },
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
*
|
*
|
||||||
* Modeled on ConnectionPicker. Loads mandate-scoped FeatureInstances filtered
|
* Modeled on ConnectionPicker. Loads mandate-scoped FeatureInstances filtered
|
||||||
* by `frontendOptions.featureCode` (e.g. "trustee", "redmine") via
|
* by `frontendOptions.featureCode` (e.g. "trustee", "redmine") via
|
||||||
* GET /api/workflows/{instanceId}/options/feature.instance?featureCode=<code>
|
* GET /api/workflow-automation/options/feature.instance?featureCode=<code>
|
||||||
*
|
*
|
||||||
* Behavior matches the rest of the editor:
|
* Behavior matches the rest of the editor:
|
||||||
* - 0 results -> hint to create a feature instance for this mandate
|
* - 0 results -> hint to create a feature instance for this mandate
|
||||||
|
|
@ -42,7 +42,7 @@ export const FeatureInstancePicker: React.FC<FieldRendererProps> = ({
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLoadError(null);
|
setLoadError(null);
|
||||||
request({
|
request({
|
||||||
url: `/api/workflows/${instanceId}/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`,
|
url: `/api/workflow-automation/options/feature.instance?featureCode=${encodeURIComponent(featureCode)}`,
|
||||||
method: 'get',
|
method: 'get',
|
||||||
})
|
})
|
||||||
.then((res: unknown) => {
|
.then((res: unknown) => {
|
||||||
|
|
|
||||||
|
|
@ -310,7 +310,7 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
|
||||||
if (!instanceId || !request) return;
|
if (!instanceId || !request) return;
|
||||||
const qs = authority ? `?authority=${encodeURIComponent(authority)}` : '';
|
const qs = authority ? `?authority=${encodeURIComponent(authority)}` : '';
|
||||||
setLoadError(null);
|
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) => {
|
.then((res: unknown) => {
|
||||||
const data = res as { options?: Array<{ value: string; label: string }> };
|
const data = res as { options?: Array<{ value: string; label: string }> };
|
||||||
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
|
setConnections((data?.options || []).map((o) => ({ id: o.value, label: o.label })));
|
||||||
|
|
@ -328,7 +328,7 @@ const ConnectionPicker: React.FC<FieldRendererProps> = ({ param, value, onChange
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections);
|
const graph = toApiGraph(dataFlow.nodes as CanvasNode[], dataFlow.connections);
|
||||||
postUpstreamPaths(request, instanceId, graph, dataFlow.currentNodeId)
|
postUpstreamPaths(request, graph, dataFlow.currentNodeId)
|
||||||
.then(({ paths }) => {
|
.then(({ paths }) => {
|
||||||
const opts = paths
|
const opts = paths
|
||||||
.filter(
|
.filter(
|
||||||
|
|
|
||||||
|
|
@ -303,7 +303,7 @@ export const DataPicker: React.FC<DataPickerProps> = ({ open,
|
||||||
if (scopeFetchKey.current === key) return; // already fetched for this state
|
if (scopeFetchKey.current === key) return; // already fetched for this state
|
||||||
scopeFetchKey.current = key;
|
scopeFetchKey.current = key;
|
||||||
const nodeShapes = (ctx.nodes ?? []).map((n) => ({ id: n.id, type: n.type }));
|
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)
|
.then(setScopeData)
|
||||||
.catch(() => setScopeData(null));
|
.catch(() => setScopeData(null));
|
||||||
}, [open, ctx?.instanceId, ctx?.request, ctx?.currentNodeId, connections, nodesRaw]);
|
}, [open, ctx?.instanceId, ctx?.request, ctx?.currentNodeId, connections, nodesRaw]);
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
import React, { useCallback, useRef, useMemo, useState, useEffect } from 'react';
|
import React, { useCallback, useRef, useMemo, useState, useEffect } from 'react';
|
||||||
import type { UdbContext } from './UnifiedDataBar';
|
import type { UdbContext } from './UnifiedDataBar';
|
||||||
import api from '../../api';
|
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 { FormGeneratorTree } from '../FormGenerator/FormGeneratorTree';
|
||||||
import { createFolderFileProvider } from '../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
|
import { createFolderFileProvider } from '../FormGenerator/FormGeneratorTree/providers/FolderFileProvider';
|
||||||
import type { TreeNode } from '../FormGenerator/FormGeneratorTree';
|
import type { TreeNode } from '../FormGenerator/FormGeneratorTree';
|
||||||
|
|
@ -17,15 +11,10 @@ interface FilesTabProps {
|
||||||
context: UdbContext;
|
context: UdbContext;
|
||||||
onFileSelect?: (fileId: string, fileName?: string) => void;
|
onFileSelect?: (fileId: string, fileName?: string) => void;
|
||||||
onSendToChat?: (items: Array<{ id: string; type: 'file' | 'group'; name: 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<FilesTabProps> = ({ context, onFileSelect, onSendToChat, onWorkflowImported }) => {
|
const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat }) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
const { request } = useApiRequest();
|
|
||||||
const { showSuccess, showError } = useToast();
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
|
const [uploadProgressPercent, setUploadProgressPercent] = useState(0);
|
||||||
|
|
@ -126,33 +115,6 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
}
|
}
|
||||||
}, [_uploadFiles]);
|
}, [_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) => {
|
const _handleSendToChat = useCallback((node: TreeNode) => {
|
||||||
onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
|
onSendToChat?.([{ id: node.id, type: node.type === 'folder' ? 'group' : 'file', name: node.name }]);
|
||||||
}, [onSendToChat]);
|
}, [onSendToChat]);
|
||||||
|
|
@ -243,7 +205,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
title={t('Eigene')}
|
title={t('Eigene')}
|
||||||
compact={true}
|
compact={true}
|
||||||
showFilter={true}
|
showFilter={true}
|
||||||
onNodeClick={_handleNodeClickWithImport}
|
onNodeClick={_handleNodeClick}
|
||||||
onSendToChat={_handleSendToChat}
|
onSendToChat={_handleSendToChat}
|
||||||
/>
|
/>
|
||||||
<FormGeneratorTree
|
<FormGeneratorTree
|
||||||
|
|
@ -255,7 +217,7 @@ const FilesTab: React.FC<FilesTabProps> = ({ context, onFileSelect, onSendToChat
|
||||||
collapsible={true}
|
collapsible={true}
|
||||||
defaultCollapsed={true}
|
defaultCollapsed={true}
|
||||||
emptyMessage={t('Keine geteilten Dateien')}
|
emptyMessage={t('Keine geteilten Dateien')}
|
||||||
onNodeClick={_handleNodeClickWithImport}
|
onNodeClick={_handleNodeClick}
|
||||||
onSendToChat={_handleSendToChat}
|
onSendToChat={_handleSendToChat}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,10 @@ import styles from './UnifiedDataBar.module.css';
|
||||||
export type UdbTab = 'chats' | 'files' | 'sources';
|
export type UdbTab = 'chats' | 'files' | 'sources';
|
||||||
|
|
||||||
/** Aufruf-Surface, in der die UDB gerade lebt. Wird an Custom-Actions
|
/** Aufruf-Surface, in der die UDB gerade lebt. Wird an Custom-Actions
|
||||||
* (z. B. `workflow.openInEditor`) weitergereicht, damit sie sich
|
* weitergereicht, damit sie sich pro Surface registrieren koennen.
|
||||||
* pro Surface registrieren können. */
|
* Bekannte Werte: 'workspace', 'workflowAutomation', 'trustee',
|
||||||
export type UdbSurface =
|
* 'standalone', 'sharepoint' — beliebig erweiterbar durch Consumer. */
|
||||||
| 'workspace'
|
export type UdbSurface = string;
|
||||||
| 'graphEditor'
|
|
||||||
| 'trustee'
|
|
||||||
| 'standalone'
|
|
||||||
| 'sharepoint';
|
|
||||||
|
|
||||||
export interface UdbContext {
|
export interface UdbContext {
|
||||||
instanceId: string;
|
instanceId: string;
|
||||||
|
|
@ -56,9 +52,6 @@ interface UnifiedDataBarProps {
|
||||||
onSendToChat_Files?: (items: AddToChat_FileItem[]) => void;
|
onSendToChat_Files?: (items: AddToChat_FileItem[]) => void;
|
||||||
onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void;
|
onSendToChat_FeatureSource?: (params: AddToChat_FeatureSource) => void;
|
||||||
onAttachDataSource?: (dsId: string) => 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;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -87,7 +80,6 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
||||||
onSendToChat_Files,
|
onSendToChat_Files,
|
||||||
onSendToChat_FeatureSource,
|
onSendToChat_FeatureSource,
|
||||||
onAttachDataSource,
|
onAttachDataSource,
|
||||||
onWorkflowImportedFromFile,
|
|
||||||
className,
|
className,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
@ -132,7 +124,6 @@ const UnifiedDataBar: React.FC<UnifiedDataBarProps> = ({
|
||||||
context={context}
|
context={context}
|
||||||
onFileSelect={onFileSelect}
|
onFileSelect={onFileSelect}
|
||||||
onSendToChat={onSendToChat_Files}
|
onSendToChat={onSendToChat_Files}
|
||||||
onWorkflowImported={onWorkflowImportedFromFile}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
|
{currentTab === 'sources' && !hideTabs?.includes('sources') && (
|
||||||
|
|
|
||||||
8
src/components/workflowAutomation/FlowEditor/index.ts
Normal file
8
src/components/workflowAutomation/FlowEditor/index.ts
Normal file
|
|
@ -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';
|
||||||
|
|
@ -3,8 +3,14 @@ import { AdminDatabaseHealthPage } from '../pages/admin/AdminDatabaseHealthPage'
|
||||||
import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage';
|
import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage';
|
||||||
import { CommcoachSessionView } from '../pages/views/commcoach';
|
import { CommcoachSessionView } from '../pages/views/commcoach';
|
||||||
import { WorkspacePage } from '../pages/views/workspace/WorkspacePage';
|
import { WorkspacePage } from '../pages/views/workspace/WorkspacePage';
|
||||||
|
import { WorkflowAutomationPage } from '../pages/workflowAutomation/WorkflowAutomationHubPage';
|
||||||
|
|
||||||
export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [
|
export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [
|
||||||
|
{
|
||||||
|
id: 'workflow-automation-editor',
|
||||||
|
pathRegex: /\/workflow-automation(?:\?.*tab=editor|$)/,
|
||||||
|
render: () => <WorkflowAutomationPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'workspace-dashboard',
|
id: 'workspace-dashboard',
|
||||||
pathRegex: /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/,
|
pathRegex: /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/,
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ import {
|
||||||
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase,
|
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase,
|
||||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
||||||
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
||||||
FaFileContract, FaRobot, FaGlobe, FaClipboardCheck,
|
FaFileContract, FaGlobe, FaClipboardCheck,
|
||||||
FaSitemap, FaCopy, FaTasks,
|
FaSitemap, FaCopy, FaTasks,
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
|
||||||
|
|
@ -53,7 +53,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
// System pages - Usage
|
// System pages - Usage
|
||||||
'page.system.billingAdmin': <FaMoneyBillAlt />,
|
'page.system.billingAdmin': <FaMoneyBillAlt />,
|
||||||
'page.system.statistics': <FaChartBar />,
|
'page.system.statistics': <FaChartBar />,
|
||||||
'page.system.automations': <FaRobot />,
|
|
||||||
'page.system.ragInventory': <FaDatabase />,
|
'page.system.ragInventory': <FaDatabase />,
|
||||||
|
|
||||||
// System pages - Workflow Automation
|
// System pages - Workflow Automation
|
||||||
|
|
|
||||||
|
|
@ -100,11 +100,11 @@ function _dotColorForIndex(index: number): string {
|
||||||
return palette[index % palette.length];
|
return palette[index % palette.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
function _collectGraphicalEditorInstanceIds(mandates: NavigationMandate[]): string[] {
|
function _collectWorkflowAutomationInstanceIds(mandates: NavigationMandate[]): string[] {
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const mandate of mandates) {
|
for (const mandate of mandates) {
|
||||||
for (const feature of mandate.features) {
|
for (const feature of mandate.features) {
|
||||||
if (feature.uiComponent === 'feature.graphicalEditor') {
|
if (feature.uiComponent === 'feature.workflowAutomation') {
|
||||||
for (const inst of feature.instances) {
|
for (const inst of feature.instances) {
|
||||||
if (inst.id && !ids.includes(inst.id)) {
|
if (inst.id && !ids.includes(inst.id)) {
|
||||||
ids.push(inst.id);
|
ids.push(inst.id);
|
||||||
|
|
@ -271,13 +271,13 @@ export function useIntegrationsOverview(): UseIntegrationsOverviewResult {
|
||||||
setError((prev) => (prev ? `${prev} | ${msg}` : msg));
|
setError((prev) => (prev ? `${prev} | ${msg}` : msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
const geIds = _collectGraphicalEditorInstanceIds(mandatesForWorkflows);
|
const waIds = _collectWorkflowAutomationInstanceIds(mandatesForWorkflows);
|
||||||
const wfLabels: string[] = [];
|
const wfLabels: string[] = [];
|
||||||
const seenWf = new Set<string>();
|
const seenWf = new Set<string>();
|
||||||
for (const instanceId of geIds.slice(0, 4)) {
|
for (const instanceId of waIds.slice(0, 4)) {
|
||||||
try {
|
try {
|
||||||
const wfRes = await api.get(`/api/workflows/${instanceId}/workflows`, {
|
const wfRes = await api.get(`/api/workflow-automation/workflows`, {
|
||||||
params: { active: 'true' },
|
params: { active: 'true', instanceId },
|
||||||
});
|
});
|
||||||
const wfData = wfRes.data;
|
const wfData = wfRes.data;
|
||||||
const list = Array.isArray(wfData)
|
const list = Array.isArray(wfData)
|
||||||
|
|
|
||||||
|
|
@ -7,25 +7,22 @@ import {
|
||||||
fetchWorkflow as fetchWorkflowFromApi,
|
fetchWorkflow as fetchWorkflowFromApi,
|
||||||
deleteWorkflow as deleteWorkflowFromApi,
|
deleteWorkflow as deleteWorkflowFromApi,
|
||||||
updateWorkflow as updateWorkflowFromApi,
|
updateWorkflow as updateWorkflowFromApi,
|
||||||
} from '../api/workflowApi';
|
} from '../api/workflowAutomationApi';
|
||||||
import { useWorkflowSelection } from '../contexts/WorkflowSelectionContext';
|
import { useWorkflowSelection } from '../contexts/WorkflowSelectionContext';
|
||||||
import { usePermissions, type UserPermissions } from './usePermissions';
|
import { usePermissions, type UserPermissions } from './usePermissions';
|
||||||
|
|
||||||
export type StartWorkflowRequest = Record<string, unknown>;
|
export type StartWorkflowRequest = Record<string, unknown>;
|
||||||
|
|
||||||
function _workflowsInstanceIdFromBaseUrl(apiBaseUrl: string | undefined): string | null {
|
function _isValidApiBaseUrl(apiBaseUrl: string | undefined): boolean {
|
||||||
if (!apiBaseUrl) return null;
|
return apiBaseUrl === '/api/workflow-automation';
|
||||||
const m = apiBaseUrl.match(/^\/api\/workflows\/([^/]+)$/);
|
|
||||||
return m ? m[1] : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _deleteWorkflowsSequential(
|
async function _deleteWorkflowsSequential(
|
||||||
request: ApiRequestFunction,
|
request: ApiRequestFunction,
|
||||||
instanceId: string,
|
|
||||||
workflowIds: string[],
|
workflowIds: string[],
|
||||||
) {
|
) {
|
||||||
for (const id of workflowIds) {
|
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' },
|
options?: { workflowId?: string; workflowMode?: 'Dynamic' | 'Automation' },
|
||||||
) {
|
) {
|
||||||
return await request({
|
return await request({
|
||||||
url: `/api/workflows/${instanceId}/execute`,
|
url: `/api/workflow-automation/execute`,
|
||||||
method: 'post',
|
method: 'post',
|
||||||
data: {
|
data: {
|
||||||
workflowId: options?.workflowId ?? (workflowData as { workflowId?: string }).workflowId,
|
workflowId: options?.workflowId ?? (workflowData as { workflowId?: string }).workflowId,
|
||||||
|
|
@ -105,10 +102,10 @@ export interface PaginationParams {
|
||||||
search?: string;
|
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 {
|
export function getWorkflowApiBaseUrl(instanceId: string | undefined, featureCode: string | undefined): string | undefined {
|
||||||
if (!instanceId || !featureCode) return undefined;
|
if (!featureCode) return undefined;
|
||||||
if (featureCode === 'graphicalEditor') return `/api/workflows/${instanceId}`;
|
if (featureCode === 'workflowAutomation') return `/api/workflow-automation`;
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -162,13 +159,8 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
|
||||||
|
|
||||||
const fetchWorkflowsData = useCallback(async (params?: PaginationParams) => {
|
const fetchWorkflowsData = useCallback(async (params?: PaginationParams) => {
|
||||||
try {
|
try {
|
||||||
if (!apiBaseUrl) {
|
if (!apiBaseUrl || !_isValidApiBaseUrl(apiBaseUrl)) {
|
||||||
console.error('useUserWorkflows: apiBaseUrl is required (missing instanceId/featureCode)');
|
console.error('useUserWorkflows: apiBaseUrl is required (missing featureCode)');
|
||||||
return;
|
|
||||||
}
|
|
||||||
const instanceId = _workflowsInstanceIdFromBaseUrl(apiBaseUrl);
|
|
||||||
if (!instanceId) {
|
|
||||||
console.error('useUserWorkflows: could not parse instanceId from apiBaseUrl');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let listParams: { pagination?: Record<string, unknown> } | undefined = undefined;
|
let listParams: { pagination?: Record<string, unknown> } | undefined = undefined;
|
||||||
|
|
@ -183,7 +175,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?:
|
||||||
listParams = { pagination: paginationObj };
|
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
|
// Handle paginated response
|
||||||
if (data && typeof data === 'object' && data !== null && 'items' in data) {
|
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
|
// Fetch a single workflow by ID
|
||||||
const fetchWorkflowById = useCallback(async (workflowId: string): Promise<UserWorkflow | null> => {
|
const fetchWorkflowById = useCallback(async (workflowId: string): Promise<UserWorkflow | null> => {
|
||||||
try {
|
try {
|
||||||
const instanceId = _workflowsInstanceIdFromBaseUrl(apiBaseUrl);
|
if (!_isValidApiBaseUrl(apiBaseUrl)) return null;
|
||||||
if (!instanceId) return null;
|
const workflow = await fetchWorkflowFromApi(request, workflowId);
|
||||||
const workflow = await fetchWorkflowFromApi(request, instanceId, workflowId);
|
|
||||||
return workflow as unknown as UserWorkflow | null;
|
return workflow as unknown as UserWorkflow | null;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error fetching workflow by ID:', error);
|
console.error('Error fetching workflow by ID:', error);
|
||||||
|
|
@ -526,8 +517,7 @@ export function useWorkflowOperations(options?: { instanceId?: string; featureCo
|
||||||
setDeletingWorkflows,
|
setDeletingWorkflows,
|
||||||
setDeleteError,
|
setDeleteError,
|
||||||
() => {
|
() => {
|
||||||
if (!instanceId) throw new Error('instanceId required');
|
return deleteWorkflowFromApi(request, workflowId);
|
||||||
return deleteWorkflowFromApi(request, instanceId, workflowId);
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: 'Failed to delete workflow',
|
default: 'Failed to delete workflow',
|
||||||
|
|
@ -563,8 +553,7 @@ export function useWorkflowOperations(options?: { instanceId?: string; featureCo
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!instanceId) throw new Error('instanceId required');
|
await _deleteWorkflowsSequential(request, workflowIds);
|
||||||
await _deleteWorkflowsSequential(request, instanceId, workflowIds);
|
|
||||||
|
|
||||||
// Add a small delay to ensure backend has time to process
|
// Add a small delay to ensure backend has time to process
|
||||||
await new Promise(resolve => setTimeout(resolve, 300));
|
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));
|
setEditingWorkflows(prev => new Set(prev).add(workflowId));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!instanceId) throw new Error('instanceId required');
|
const updatedWorkflow = await updateWorkflowFromApi(request, workflowId, {
|
||||||
const updatedWorkflow = await updateWorkflowFromApi(request, instanceId, workflowId, {
|
|
||||||
label: updateData.name,
|
label: updateData.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 <Navigate to={`/workflow-automation${qs ? `?${qs}` : ''}`} replace />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AutomationsDashboardPage;
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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, unknown>): 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<string, string>)) {
|
|
||||||
return (label as Record<string, string>).de ?? (label as Record<string, string>).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<string, unknown> | 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<Automation2Task[]>([]);
|
|
||||||
const [completedRuns, setCompletedRuns] = useState<CompletedRun[]>([]);
|
|
||||||
const [startableWorkflows, setStartableWorkflows] = useState<Automation2Workflow[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [completedExpanded, setCompletedExpanded] = useState(false);
|
|
||||||
const [outputExpanded, setOutputExpanded] = useState(true);
|
|
||||||
const [submitting, setSubmitting] = useState<string | null>(null);
|
|
||||||
const [dismissingTaskId, setDismissingTaskId] = useState<string | null>(null);
|
|
||||||
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null);
|
|
||||||
const [formStartWorkflow, setFormStartWorkflow] = useState<Automation2Workflow | null>(null);
|
|
||||||
const [formStartFields, setFormStartFields] = useState<WorkflowRuntimeFormFieldRow[]>([]);
|
|
||||||
const [startFormData, setStartFormData] = useState<Record<string, unknown>>({});
|
|
||||||
|
|
||||||
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<string, unknown>) => {
|
|
||||||
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 (
|
|
||||||
<div className={styles.placeholder}>
|
|
||||||
<p>{t('keine Featureinstanz gefunden')}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className={styles.loading}>
|
|
||||||
<FaSpinner className={styles.spinner} />
|
|
||||||
<p>{t('lade Tasks')}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.pageLayout}>
|
|
||||||
<div className={styles.mainColumn}>
|
|
||||||
<div className={styles.container}>
|
|
||||||
{/* Open tasks */}
|
|
||||||
<section className={styles.section}>
|
|
||||||
<h3 className={styles.sectionTitle}>
|
|
||||||
{t('Offene Tasks')}
|
|
||||||
{openTasks.length > 0 && <span className={styles.badge}>{openTasks.length}</span>}
|
|
||||||
</h3>
|
|
||||||
{openTasks.length === 0 ? (
|
|
||||||
<p className={styles.empty}>{t('keine offenen Tasks')}</p>
|
|
||||||
) : (
|
|
||||||
<div className={styles.taskList}>
|
|
||||||
{openTasks.map((task) => (
|
|
||||||
<TaskCard
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
instanceId={instanceId ?? undefined}
|
|
||||||
onSubmit={(result) => handleComplete(task.id, result)}
|
|
||||||
submitting={submitting === task.id}
|
|
||||||
showDismiss
|
|
||||||
onDismiss={() => handleDismissOpenTask(task.id)}
|
|
||||||
dismissing={dismissingTaskId === task.id}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Completed tasks */}
|
|
||||||
<section className={styles.section}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.completedHeader}
|
|
||||||
onClick={() => setCompletedExpanded((p) => !p)}
|
|
||||||
>
|
|
||||||
{completedExpanded ? <FaChevronDown /> : <FaChevronRight />}
|
|
||||||
<span>{t('erledigte Tasks')}</span>
|
|
||||||
{completedTasks.length > 0 && (
|
|
||||||
<span className={styles.badge}>{completedTasks.length}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{completedExpanded && (
|
|
||||||
<div className={styles.completedList}>
|
|
||||||
{completedTasks.length === 0 ? (
|
|
||||||
<p className={styles.empty}>{t('keine erledigten Tasks')}</p>
|
|
||||||
) : (
|
|
||||||
completedTasks.map((task) => (
|
|
||||||
<TaskCard
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
instanceId={instanceId ?? undefined}
|
|
||||||
onSubmit={(result) => handleComplete(task.id, result)}
|
|
||||||
submitting={submitting === task.id}
|
|
||||||
readOnly
|
|
||||||
/>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Output – abgeschlossene Workflows mit Ergebnis */}
|
|
||||||
<section className={styles.section}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.completedHeader}
|
|
||||||
onClick={() => setOutputExpanded((p) => !p)}
|
|
||||||
>
|
|
||||||
{outputExpanded ? <FaChevronDown /> : <FaChevronRight />}
|
|
||||||
<span>{t('Resultate')}</span>
|
|
||||||
{completedRuns.length > 0 && (
|
|
||||||
<span className={styles.badge}>{completedRuns.length}</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{outputExpanded && (
|
|
||||||
<div className={styles.completedList}>
|
|
||||||
{completedRuns.length === 0 ? (
|
|
||||||
<p className={styles.empty}>
|
|
||||||
{t('Keine abgeschlossenen Workflows. Führen Sie einen Workflow aus (z.B. im Editor), um hier die Ergebnisse zu sehen.')}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
completedRuns.map((run) => (
|
|
||||||
<OutputCard key={run.id} run={run} instanceId={instanceId ?? undefined} />
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className={styles.startSidebar} aria-label={t('Workflows starten')}>
|
|
||||||
<h3 className={styles.startSidebarTitle}>{t('Workflow starten')}</h3>
|
|
||||||
<div className={styles.startSidebarList}>
|
|
||||||
{startableWorkflows.length === 0 ? (
|
|
||||||
<p className={styles.empty}>
|
|
||||||
{t('Keine aktiven Workflows mit manuellem oder Formular-Start.')}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
startableWorkflows.map((wf) => {
|
|
||||||
const primary = getPrimaryEntryPoint(wf);
|
|
||||||
const kind = primary?.kind ?? 'manual';
|
|
||||||
return (
|
|
||||||
<div key={wf.id} className={styles.startWorkflowRow}>
|
|
||||||
<div className={styles.startWorkflowInfo}>
|
|
||||||
<span className={styles.startWorkflowName} title={wf.label}>
|
|
||||||
{wf.label || wf.id}
|
|
||||||
</span>
|
|
||||||
<span className={styles.startWorkflowKind}>
|
|
||||||
{primaryKindLabel(kind)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.startButton}
|
|
||||||
title={t('Workflow ausführen')}
|
|
||||||
disabled={executingWorkflowId === wf.id}
|
|
||||||
onClick={() => handleStartWorkflow(wf)}
|
|
||||||
>
|
|
||||||
{executingWorkflowId === wf.id ? (
|
|
||||||
<FaSpinner className={styles.spinner} />
|
|
||||||
) : (
|
|
||||||
<FaPlay />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<Popup
|
|
||||||
isOpen={formStartWorkflow != null}
|
|
||||||
title={t('Formular ausfüllen')}
|
|
||||||
onClose={() => setFormStartWorkflow(null)}
|
|
||||||
closable={
|
|
||||||
!(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
|
|
||||||
}
|
|
||||||
closeOnEscape={
|
|
||||||
!(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
|
|
||||||
}
|
|
||||||
size="medium"
|
|
||||||
footerContent={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => void handleFormStartSubmit()}
|
|
||||||
disabled={
|
|
||||||
!formStartRequiredOk ||
|
|
||||||
(formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id)
|
|
||||||
}
|
|
||||||
className={styles.popupSubmitButton}
|
|
||||||
>
|
|
||||||
{formStartWorkflow != null && executingWorkflowId === formStartWorkflow.id
|
|
||||||
? t('wird gesendet')
|
|
||||||
: t('absenden')}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<WorkflowRuntimeFormFields
|
|
||||||
fields={formStartFields}
|
|
||||||
formData={startFormData}
|
|
||||||
setFormData={setStartFormData}
|
|
||||||
formFieldsClassName={styles.formFields}
|
|
||||||
/>
|
|
||||||
</Popup>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 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<string, unknown>;
|
|
||||||
const docs = (o.documents ?? o.documentList ?? []) as Array<Record<string, unknown>>;
|
|
||||||
if (!Array.isArray(docs)) continue;
|
|
||||||
for (const d of docs) {
|
|
||||||
const fileId = (d.validationMetadata as Record<string, unknown>)?.fileId as string | undefined;
|
|
||||||
if (fileId) {
|
|
||||||
files.push({
|
|
||||||
name: String(d.documentName ?? d.fileName ?? t('Datei')),
|
|
||||||
fileId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className={styles.taskCard}>
|
|
||||||
<div className={styles.taskMeta}>
|
|
||||||
<div className={styles.taskMetaRow}>
|
|
||||||
<span className={styles.metaLabel}>{t('Workflow')}</span>
|
|
||||||
<span className={styles.metaValue}>{run.workflowLabel || run.workflowId || '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.taskMetaRow}>
|
|
||||||
<span className={styles.metaLabel}>{t('Abgeschlossen')}</span>
|
|
||||||
<span className={styles.metaValue}>{formatTimestamp(ts)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{files.length > 0 ? (
|
|
||||||
<div className={styles.outputContent}>
|
|
||||||
<span className={styles.metaLabel}>{t('Dateien')}</span>
|
|
||||||
<ul className={styles.uploadedList}>
|
|
||||||
{files.map((f, j) => (
|
|
||||||
<li key={j}>
|
|
||||||
<Link
|
|
||||||
to="/basedata/files"
|
|
||||||
className={styles.downloadLink}
|
|
||||||
>
|
|
||||||
{f.name}
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className={styles.empty}>{t('kein Output, z.B. Workflow ohne')}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TaskCardProps {
|
|
||||||
task: Automation2Task;
|
|
||||||
instanceId?: string;
|
|
||||||
onSubmit: (result: Record<string, unknown>) => 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<TaskCardProps> = ({
|
|
||||||
task,
|
|
||||||
instanceId,
|
|
||||||
onSubmit,
|
|
||||||
submitting,
|
|
||||||
readOnly = false,
|
|
||||||
showDismiss = false,
|
|
||||||
onDismiss,
|
|
||||||
dismissing = false,
|
|
||||||
}) => {
|
|
||||||
const { t } = useLanguage();
|
|
||||||
const { handleFileUpload } = useFileOperations();
|
|
||||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
|
||||||
const [formPopupOpen, setFormPopupOpen] = useState(false);
|
|
||||||
const [uploadedFiles, setUploadedFiles] = useState<Array<{ id: string; fileName: string; file?: Record<string, unknown> }>>([]);
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(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 = (
|
|
||||||
<WorkflowRuntimeFormFields
|
|
||||||
fields={inputFormFields}
|
|
||||||
formData={formData}
|
|
||||||
setFormData={setFormData}
|
|
||||||
formFieldsClassName={styles.formFields}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormPopupOpen(true)}
|
|
||||||
disabled={submitting}
|
|
||||||
className={styles.openFormButton}
|
|
||||||
>
|
|
||||||
Formular bearbeiten
|
|
||||||
</button>
|
|
||||||
<Popup
|
|
||||||
isOpen={formPopupOpen}
|
|
||||||
title={t('Formular ausfüllen')}
|
|
||||||
onClose={() => setFormPopupOpen(false)}
|
|
||||||
size="medium"
|
|
||||||
footerContent={
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
// Match node output shape used by refs (payload.*) and outputPreviewRegistry.input.form
|
|
||||||
onSubmit({ payload: formData });
|
|
||||||
setFormPopupOpen(false);
|
|
||||||
}}
|
|
||||||
disabled={submitting || !inputFormRequiredOk}
|
|
||||||
className={styles.popupSubmitButton}
|
|
||||||
>
|
|
||||||
{submitting ? t('wird gesendet') : t('absenden')}
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{formContent}
|
|
||||||
</Popup>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'input.approval':
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{config.title != null && String(config.title) !== '' && <h4>{String(config.title)}</h4>}
|
|
||||||
{config.description != null && String(config.description) !== '' && <p>{String(config.description)}</p>}
|
|
||||||
<div className={styles.approvalButtons}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSubmit({ approved: true })}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{t('Genehmigen')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSubmit({ approved: false })}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{t('Ablehnen')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'input.comment':
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<textarea
|
|
||||||
placeholder={(config.placeholder as string) ?? t('Kommentar...')}
|
|
||||||
value={(formData.comment as string) ?? ''}
|
|
||||||
onChange={(e) => setFormData({ comment: e.target.value })}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSubmit(formData)}
|
|
||||||
disabled={
|
|
||||||
submitting ||
|
|
||||||
((config.required !== false) && !formData.comment)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t('Absenden')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'input.selection': {
|
|
||||||
const options =
|
|
||||||
(config.options as Array<{ value: string; label: string }>) ?? [];
|
|
||||||
const multiple = config.multiple as boolean;
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{options.map((o) => (
|
|
||||||
<label key={o.value}>
|
|
||||||
<input
|
|
||||||
type={multiple ? 'checkbox' : 'radio'}
|
|
||||||
name={task.id}
|
|
||||||
value={o.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (multiple) {
|
|
||||||
const prev = (formData.selected as string[]) ?? [];
|
|
||||||
const next = e.target.checked
|
|
||||||
? [...prev, o.value]
|
|
||||||
: prev.filter((v) => v !== o.value);
|
|
||||||
setFormData((p) => ({ ...p, selected: next }));
|
|
||||||
} else {
|
|
||||||
setFormData((p) => ({ ...p, selected: o.value }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{o.label || o.value}
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSubmit(formData)}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{t('Absenden')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'input.confirmation':
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>{(config.question as string) ?? t('Bestätigen?')}</p>
|
|
||||||
<div className={styles.approvalButtons}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSubmit({ confirmed: true })}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{typeof config.confirmLabel === 'string' ? config.confirmLabel : t('Bestätigen')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSubmit({ confirmed: false })}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{typeof config.rejectLabel === 'string' ? config.rejectLabel : t('Ablehnen')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
case 'input.upload': {
|
|
||||||
const acceptStr = getAcceptStringFromConfig(config);
|
|
||||||
const maxSizeMB = (config.maxSize as number) ?? 10;
|
|
||||||
const allowMultiple = (config.multiple as boolean) ?? false;
|
|
||||||
const maxSizeBytes = maxSizeMB * 1024 * 1024;
|
|
||||||
|
|
||||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const files = e.target.files;
|
|
||||||
if (!files?.length || !instanceId) return;
|
|
||||||
if (!allowMultiple && files.length > 1) {
|
|
||||||
setUploadError('Nur eine Datei erlaubt.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setUploadError(null);
|
|
||||||
setUploading(true);
|
|
||||||
const results: Array<{ id: string; fileName: string; file?: Record<string, unknown> }> = [];
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
const file = files[i];
|
|
||||||
if (file.size > maxSizeBytes) {
|
|
||||||
setUploadError(`Datei "${file.name}" zu groß (max. ${maxSizeMB} MB).`);
|
|
||||||
setUploading(false);
|
|
||||||
e.target.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (acceptStr && acceptStr !== '*' && !fileMatchesAccept(file, acceptStr)) {
|
|
||||||
setUploadError(
|
|
||||||
`Die Datei „${file.name}“ hat ein nicht erlaubtes Format. Bitte eine Datei mit passender Endung verwenden (laut Upload-Schritt im Workflow).`,
|
|
||||||
);
|
|
||||||
setUploading(false);
|
|
||||||
e.target.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await handleFileUpload(
|
|
||||||
file,
|
|
||||||
task.workflowId ?? undefined,
|
|
||||||
instanceId ?? undefined
|
|
||||||
);
|
|
||||||
if (result?.success && result?.fileData) {
|
|
||||||
const fileMeta = result.fileData?.file ?? result.fileData;
|
|
||||||
const fileId = fileMeta?.id ?? fileMeta?.fileName;
|
|
||||||
if (fileId) {
|
|
||||||
results.push({
|
|
||||||
id: fileId,
|
|
||||||
fileName: fileMeta?.fileName ?? file.name,
|
|
||||||
file: fileMeta,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else if (result?.error) {
|
|
||||||
setUploadError(result.error);
|
|
||||||
setUploading(false);
|
|
||||||
e.target.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
const msg = (err as { response?: { data?: { detail?: string } }; message?: string })?.response?.data?.detail ?? (err as Error)?.message ?? t('Upload fehlgeschlagen');
|
|
||||||
setUploadError(msg);
|
|
||||||
setUploading(false);
|
|
||||||
e.target.value = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setUploadedFiles((prev) => (allowMultiple ? [...prev, ...results] : results));
|
|
||||||
setUploading(false);
|
|
||||||
e.target.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmitUpload = () => {
|
|
||||||
if (uploadedFiles.length === 0) {
|
|
||||||
setUploadError(t('Bitte mindestens eine Datei hochladen.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const file = uploadedFiles[0]?.file ?? { id: uploadedFiles[0]?.id, fileName: uploadedFiles[0]?.fileName };
|
|
||||||
const files = uploadedFiles.map((u) => u.file ?? { id: u.id, fileName: u.fileName });
|
|
||||||
const fileIds = uploadedFiles.map((u) => u.id);
|
|
||||||
onSubmit({
|
|
||||||
file,
|
|
||||||
files,
|
|
||||||
fileIds,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.uploadTaskBlock}>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={acceptStr === '*' ? undefined : acceptStr || undefined}
|
|
||||||
multiple={allowMultiple}
|
|
||||||
onChange={handleFileSelect}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
disabled={submitting || uploading}
|
|
||||||
className={styles.uploadButton}
|
|
||||||
>
|
|
||||||
<FaUpload /> {uploading ? t('wird hochgeladen') : t('Dateien auswählen')}
|
|
||||||
</button>
|
|
||||||
{uploadError && <p className={styles.uploadError}>{uploadError}</p>}
|
|
||||||
{uploadedFiles.length > 0 && (
|
|
||||||
<ul className={styles.uploadedList}>
|
|
||||||
{uploadedFiles.map((u) => (
|
|
||||||
<li key={u.id}>{u.fileName}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSubmitUpload}
|
|
||||||
disabled={submitting || uploading || uploadedFiles.length === 0}
|
|
||||||
className={styles.popupSubmitButton}
|
|
||||||
>
|
|
||||||
{submitting ? t('wird gesendet') : t('absenden')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'input.review':
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>{t('Review-Inhalt anzeigen, Feedback')}</p>
|
|
||||||
<textarea
|
|
||||||
placeholder={t('Feedback…')}
|
|
||||||
value={(formData.feedback as string) ?? ''}
|
|
||||||
onChange={(e) => setFormData({ feedback: e.target.value })}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSubmit(formData)}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{t('Absenden')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p>{t('Unbekannter Task-Typ: {typ}', { typ: String(nodeType) })}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSubmit({})}
|
|
||||||
disabled={submitting}
|
|
||||||
>
|
|
||||||
{t('Absenden')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cardClass = showDismiss
|
|
||||||
? `${styles.taskCard} ${styles.taskCardDismissable}`
|
|
||||||
: styles.taskCard;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={cardClass}>
|
|
||||||
{showDismiss && onDismiss ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={styles.dismissOpenTaskBtn}
|
|
||||||
title={t('Task entfernen und Ausführung abbrechen')}
|
|
||||||
aria-label={t('Task entfernen und Ausführung abbrechen')}
|
|
||||||
disabled={submitting || dismissing}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDismiss();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{dismissing ? <FaSpinner className={styles.spinner} /> : <FaTimes />}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
<div className={styles.taskMeta}>
|
|
||||||
<div className={styles.taskMetaRow}>
|
|
||||||
<span className={styles.metaLabel}>{t('Workflow')}</span>
|
|
||||||
<span className={styles.metaValue}>
|
|
||||||
{task.workflowLabel || task.workflowId || '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.taskMetaRow}>
|
|
||||||
<span className={styles.metaLabel}>{t('erstellt')}</span>
|
|
||||||
<span className={styles.metaValue}>
|
|
||||||
{formatTimestamp(task.createdAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={styles.taskMetaRow}>
|
|
||||||
<span className={styles.metaLabel}>{t('fällig')}</span>
|
|
||||||
<span className={styles.metaValue}>
|
|
||||||
{formatTimestamp(task.dueAt)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{stepLabel && (
|
|
||||||
<div className={styles.taskMetaRow}>
|
|
||||||
<span className={styles.metaLabel}>{t('Schritt')}</span>
|
|
||||||
<span className={styles.metaValue}>{stepLabel}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={styles.taskMetaRow}>
|
|
||||||
<span className={styles.metaLabel}>{t('Typ')}</span>
|
|
||||||
<span className={styles.metaValue}>
|
|
||||||
{_nodeTypeLabel(nodeType, t)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
{renderInput()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -106,7 +106,7 @@ export const TrusteeAbschlussView: React.FC = () => {
|
||||||
const _load = async () => {
|
const _load = async () => {
|
||||||
setWorkflowsLoading(true);
|
setWorkflowsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/api/workflows/${instanceId}/workflows`);
|
const res = await api.get(`/api/workflow-automation/workflows`, { params: { targetFeatureInstanceId: instanceId } });
|
||||||
const items: WorkflowSummary[] = (res.data?.workflows || res.data?.items || []).map((w: any) => ({
|
const items: WorkflowSummary[] = (res.data?.workflows || res.data?.items || []).map((w: any) => ({
|
||||||
id: w.id,
|
id: w.id,
|
||||||
label: w.label,
|
label: w.label,
|
||||||
|
|
@ -141,7 +141,7 @@ export const TrusteeAbschlussView: React.FC = () => {
|
||||||
if (!instanceId || !rid || isPollingRef.current) return;
|
if (!instanceId || !rid || isPollingRef.current) return;
|
||||||
isPollingRef.current = true;
|
isPollingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/api/workflows/${instanceId}/runs/${rid}/steps`);
|
const res = await api.get(`/api/workflow-automation/runs/${rid}/steps`);
|
||||||
const steps: any[] = Array.isArray(res?.data?.steps) ? res.data.steps : [];
|
const steps: any[] = Array.isArray(res?.data?.steps) ? res.data.steps : [];
|
||||||
const completed = steps.filter((s) => s.status === 'completed');
|
const completed = steps.filter((s) => s.status === 'completed');
|
||||||
const failed = steps.filter((s) => s.status === 'failed');
|
const failed = steps.filter((s) => s.status === 'failed');
|
||||||
|
|
@ -201,8 +201,9 @@ export const TrusteeAbschlussView: React.FC = () => {
|
||||||
setRunError(null);
|
setRunError(null);
|
||||||
setRunSummary(t('Workflow wird gestartet…'));
|
setRunSummary(t('Workflow wird gestartet…'));
|
||||||
try {
|
try {
|
||||||
const res = await api.post(`/api/workflows/${instanceId}/execute`, {
|
const res = await api.post(`/api/workflow-automation/workflows/${wf.id}/execute`, {
|
||||||
workflowId: wf.id,
|
workflowId: wf.id,
|
||||||
|
targetInstanceId: instanceId,
|
||||||
payload: { dateFrom: period.fromDate, dateTo: period.toDate },
|
payload: { dateFrom: period.fromDate, dateTo: period.toDate },
|
||||||
});
|
});
|
||||||
const rid = res?.data?.runId;
|
const rid = res?.data?.runId;
|
||||||
|
|
@ -329,10 +330,10 @@ export const TrusteeAbschlussView: React.FC = () => {
|
||||||
{runState === 'completed' && runId && (
|
{runState === 'completed' && runId && (
|
||||||
<div style={{ marginTop: '0.5rem' }}>
|
<div style={{ marginTop: '0.5rem' }}>
|
||||||
<a
|
<a
|
||||||
href={`/automations?tab=workspace&runId=${runId}`}
|
href={`/workflow-automation?tab=detail&runId=${runId}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(`/automations?tab=workspace&runId=${runId}`);
|
navigate(`/workflow-automation?tab=detail&runId=${runId}`);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
||||||
|
|
|
||||||
|
|
@ -145,7 +145,7 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
const _load = async () => {
|
const _load = async () => {
|
||||||
setWorkflowsLoading(true);
|
setWorkflowsLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/api/workflows/${instanceId}/workflows`);
|
const res = await api.get(`/api/workflow-automation/workflows`, { params: { targetFeatureInstanceId: instanceId } });
|
||||||
const items: WorkflowSummary[] = (res.data?.workflows || res.data?.items || []).map((w: any) => ({
|
const items: WorkflowSummary[] = (res.data?.workflows || res.data?.items || []).map((w: any) => ({
|
||||||
id: w.id,
|
id: w.id,
|
||||||
label: w.label,
|
label: w.label,
|
||||||
|
|
@ -181,7 +181,7 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
if (!instanceId || !rid || isPollingRef.current) return;
|
if (!instanceId || !rid || isPollingRef.current) return;
|
||||||
isPollingRef.current = true;
|
isPollingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.get(`/api/workflows/${instanceId}/runs/${rid}/steps`);
|
const res = await api.get(`/api/workflow-automation/runs/${rid}/steps`);
|
||||||
const steps: any[] = Array.isArray(res?.data?.steps) ? res.data.steps : [];
|
const steps: any[] = Array.isArray(res?.data?.steps) ? res.data.steps : [];
|
||||||
const completed = steps.filter((s) => s.status === 'completed');
|
const completed = steps.filter((s) => s.status === 'completed');
|
||||||
const failed = steps.filter((s) => s.status === 'failed');
|
const failed = steps.filter((s) => s.status === 'failed');
|
||||||
|
|
@ -285,7 +285,8 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
payload.documentList = [budgetFileId];
|
payload.documentList = [budgetFileId];
|
||||||
}
|
}
|
||||||
executeBody.payload = payload;
|
executeBody.payload = payload;
|
||||||
const res = await api.post(`/api/workflows/${instanceId}/execute`, executeBody);
|
executeBody.targetInstanceId = instanceId;
|
||||||
|
const res = await api.post(`/api/workflow-automation/workflows/${wf.id}/execute`, executeBody);
|
||||||
const rid = res?.data?.runId;
|
const rid = res?.data?.runId;
|
||||||
if (rid) {
|
if (rid) {
|
||||||
setRunId(rid);
|
setRunId(rid);
|
||||||
|
|
@ -464,10 +465,10 @@ export const TrusteeAnalyseView: React.FC = () => {
|
||||||
{t('Workflow abgeschlossen.')}
|
{t('Workflow abgeschlossen.')}
|
||||||
</span>
|
</span>
|
||||||
<a
|
<a
|
||||||
href={`/automations?tab=workspace&runId=${runId}`}
|
href={`/workflow-automation?tab=detail&runId=${runId}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
navigate(`/automations?tab=workspace&runId=${runId}`);
|
navigate(`/workflow-automation?tab=detail&runId=${runId}`);
|
||||||
}}
|
}}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
display: 'inline-flex', alignItems: 'center', gap: '0.375rem',
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,9 @@
|
||||||
* Allows users to connect their Microsoft account, select a SharePoint folder,
|
* Allows users to connect their Microsoft account, select a SharePoint folder,
|
||||||
* and activate daily automation for expense extraction.
|
* and activate daily automation for expense extraction.
|
||||||
*
|
*
|
||||||
* Uses the consolidated workflow engine via /api/workflows/{instanceId}/.
|
* Uses the workflow-automation API via /api/workflow-automation/....
|
||||||
* The routes accept any feature instanceId the user has access to (not limited
|
* Trustee users have FeatureAccess on their instance; the execute endpoint
|
||||||
* to graphicalEditor instances).
|
* accepts targetInstanceId in the body and checks FeatureAccess on it.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
|
@ -181,7 +181,7 @@ export const TrusteeExpenseImportView: React.FC<TrusteeExpenseImportViewProps> =
|
||||||
|
|
||||||
setIsLoadingWorkflow(true);
|
setIsLoadingWorkflow(true);
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/api/workflows/${instanceId}/workflows`);
|
const response = await api.get(`/api/workflow-automation/workflows`, { params: { targetFeatureInstanceId: instanceId } });
|
||||||
const workflows = response.data?.workflows || response.data?.items || [];
|
const workflows = response.data?.workflows || response.data?.items || [];
|
||||||
|
|
||||||
const expenseWorkflow = workflows.find((wf: any) =>
|
const expenseWorkflow = workflows.find((wf: any) =>
|
||||||
|
|
@ -368,12 +368,13 @@ export const TrusteeExpenseImportView: React.FC<TrusteeExpenseImportViewProps> =
|
||||||
let response;
|
let response;
|
||||||
if (existingWorkflow) {
|
if (existingWorkflow) {
|
||||||
response = await api.put(
|
response = await api.put(
|
||||||
`/api/workflows/${instanceId}/workflows/${existingWorkflow.id}`,
|
`/api/workflow-automation/workflows/${existingWorkflow.id}`,
|
||||||
{
|
{
|
||||||
label: EXPENSE_IMPORT_LABEL,
|
label: EXPENSE_IMPORT_LABEL,
|
||||||
graph,
|
graph,
|
||||||
active: activate,
|
active: activate,
|
||||||
invocations,
|
invocations,
|
||||||
|
targetFeatureInstanceId: instanceId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const msg = t('Ausgabenimport-Workflow aktualisiert und');
|
const msg = t('Ausgabenimport-Workflow aktualisiert und');
|
||||||
|
|
@ -381,12 +382,14 @@ export const TrusteeExpenseImportView: React.FC<TrusteeExpenseImportViewProps> =
|
||||||
showSuccess(t('Erfolg'), msg);
|
showSuccess(t('Erfolg'), msg);
|
||||||
} else {
|
} else {
|
||||||
response = await api.post(
|
response = await api.post(
|
||||||
`/api/workflows/${instanceId}/workflows`,
|
`/api/workflow-automation/workflows`,
|
||||||
{
|
{
|
||||||
label: EXPENSE_IMPORT_LABEL,
|
label: EXPENSE_IMPORT_LABEL,
|
||||||
graph,
|
graph,
|
||||||
active: activate,
|
active: activate,
|
||||||
invocations,
|
invocations,
|
||||||
|
targetFeatureInstanceId: instanceId,
|
||||||
|
mandateId,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
const msg = t('Ausgabenimport-Workflow erstellt und aktiviert! Er wird täglich um 22:00 Uhr ausgeführt.');
|
const msg = t('Ausgabenimport-Workflow erstellt und aktiviert! Er wird täglich um 22:00 Uhr ausgeführt.');
|
||||||
|
|
@ -430,8 +433,8 @@ export const TrusteeExpenseImportView: React.FC<TrusteeExpenseImportViewProps> =
|
||||||
DEFAULT_EXTRACTION_PROMPT
|
DEFAULT_EXTRACTION_PROMPT
|
||||||
);
|
);
|
||||||
await api.post(
|
await api.post(
|
||||||
`/api/workflows/${instanceId}/execute`,
|
`/api/workflow-automation/execute`,
|
||||||
{ graph }
|
{ graph, targetInstanceId: instanceId }
|
||||||
);
|
);
|
||||||
showSuccess(t('Gestartet'), t('Workflow gestartet. Extrahieren → Verarbeiten → Sync wird einmal ausgeführt.'));
|
showSuccess(t('Gestartet'), t('Workflow gestartet. Extrahieren → Verarbeiten → Sync wird einmal ausgeführt.'));
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
|
@ -451,7 +454,7 @@ export const TrusteeExpenseImportView: React.FC<TrusteeExpenseImportViewProps> =
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.put(
|
await api.put(
|
||||||
`/api/workflows/${instanceId}/workflows/${existingWorkflow.id}`,
|
`/api/workflow-automation/workflows/${existingWorkflow.id}`,
|
||||||
{ active: false }
|
{ active: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@
|
||||||
*
|
*
|
||||||
* Mobile-friendly scan/upload: photo, drag-and-drop, or file picker.
|
* Mobile-friendly scan/upload: photo, drag-and-drop, or file picker.
|
||||||
* Uploads files, then starts the trustee pipeline (extract → process → sync)
|
* Uploads files, then starts the trustee pipeline (extract → process → sync)
|
||||||
* via the consolidated graphicalEditor execution engine.
|
* via the consolidated workflowAutomation execution engine.
|
||||||
*
|
*
|
||||||
* The /api/workflows/ routes accept any feature instanceId the user has access to;
|
* Uses the workflow-automation API (/api/workflow-automation/...) which accepts
|
||||||
* no separate graphicalEditor instance is needed.
|
* targetInstanceId in the body and checks FeatureAccess on it for RBAC.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback, useContext, useEffect, useRef } from 'react';
|
import React, { useState, useCallback, useContext, useEffect, useRef } from 'react';
|
||||||
|
|
@ -139,7 +139,7 @@ export const TrusteeScanUploadView: React.FC<TrusteeScanUploadViewProps> = ({ em
|
||||||
isPollingRef.current = true;
|
isPollingRef.current = true;
|
||||||
try {
|
try {
|
||||||
const stepsRes = await api.get(
|
const stepsRes = await api.get(
|
||||||
`/api/workflows/${instanceId}/runs/${runId}/steps`
|
`/api/workflow-automation/runs/${runId}/steps`
|
||||||
);
|
);
|
||||||
const steps = Array.isArray(stepsRes?.data?.steps) ? stepsRes.data.steps : [];
|
const steps = Array.isArray(stepsRes?.data?.steps) ? stepsRes.data.steps : [];
|
||||||
|
|
||||||
|
|
@ -229,8 +229,8 @@ export const TrusteeScanUploadView: React.FC<TrusteeScanUploadViewProps> = ({ em
|
||||||
const fileIds = uploadedFiles.map((f) => f.fileId);
|
const fileIds = uploadedFiles.map((f) => f.fileId);
|
||||||
const graph = _buildScanUploadGraph(instanceId, fileIds, DEFAULT_EXTRACTION_PROMPT);
|
const graph = _buildScanUploadGraph(instanceId, fileIds, DEFAULT_EXTRACTION_PROMPT);
|
||||||
const response = await api.post(
|
const response = await api.post(
|
||||||
`/api/workflows/${instanceId}/execute`,
|
`/api/workflow-automation/execute`,
|
||||||
{ graph }
|
{ graph, targetInstanceId: instanceId }
|
||||||
);
|
);
|
||||||
const runId = response?.data?.runId || null;
|
const runId = response?.data?.runId || null;
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
/**
|
/**
|
||||||
* Builds graphicalEditor-compatible graph structures for trustee pipeline execution.
|
* Builds workflowAutomation-compatible graph structures for trustee pipeline execution.
|
||||||
*
|
*
|
||||||
* The consolidated automation system runs all workflows through the graphicalEditor
|
* The consolidated automation system runs all workflows through the workflowAutomation
|
||||||
* execution engine (POST /api/workflows/{instanceId}/execute). These helpers build
|
* execution engine (POST /api/workflow-automation/execute). These helpers build
|
||||||
* the graph format expected by that engine: { nodes, connections } with _method/_action
|
* the graph format expected by that engine: { nodes, connections } with _method/_action
|
||||||
* mappings to the unified Action Library.
|
* mappings to the unified Action Library.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
/**
|
/**
|
||||||
* GraphicalEditorPage
|
* WorkflowEditorPage
|
||||||
*
|
*
|
||||||
* Thin wrapper: passes instance context to FlowEditor which now owns the full layout
|
* Thin wrapper: passes mandate context to FlowEditor which owns the full layout
|
||||||
* including the Workspace panel (Chats/Dateien/Quellen) on the left.
|
* including the Workspace panel (Chats/Dateien/Quellen) on the left.
|
||||||
|
*
|
||||||
|
* mandateId is the primary context. instanceId is an internal access gate for
|
||||||
|
* workspace datasource APIs -- it does NOT define the workflow scope.
|
||||||
*/
|
*/
|
||||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
@ -12,12 +15,14 @@ import { Automation2FlowEditor as FlowEditor } from '../../../components/FlowEdi
|
||||||
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from '../../../components/FlowEditor';
|
import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from '../../../components/FlowEditor';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
|
|
||||||
interface GraphicalEditorPageProps {
|
interface WorkflowEditorPageProps {
|
||||||
|
/** Access gate instanceId for workspace datasource APIs */
|
||||||
persistentInstanceId?: string;
|
persistentInstanceId?: string;
|
||||||
|
/** Primary context: mandate the editor operates in */
|
||||||
persistentMandateId?: string;
|
persistentMandateId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
|
export const WorkflowEditorPage: React.FC<WorkflowEditorPageProps> = ({
|
||||||
persistentInstanceId,
|
persistentInstanceId,
|
||||||
persistentMandateId,
|
persistentMandateId,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -37,7 +42,7 @@ export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
|
||||||
}
|
}
|
||||||
}, [workflowIdFromUrl]);
|
}, [workflowIdFromUrl]);
|
||||||
|
|
||||||
const { t, currentLanguage } = useLanguage();
|
const { currentLanguage } = useLanguage();
|
||||||
const language = (currentLanguage?.slice(0, 2) || 'de') as string;
|
const language = (currentLanguage?.slice(0, 2) || 'de') as string;
|
||||||
|
|
||||||
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
|
||||||
|
|
@ -45,7 +50,7 @@ export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
|
||||||
const [featureDataSources, setFeatureDataSources] = useState<EditorFeatureDataSource[]>([]);
|
const [featureDataSources, setFeatureDataSources] = useState<EditorFeatureDataSource[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) { setDataSources([]); return; }
|
||||||
api.get(`/api/workspace/${instanceId}/datasources`)
|
api.get(`/api/workspace/${instanceId}/datasources`)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
const list = (res.data.dataSources || res.data || []).map((d: any) => ({
|
const list = (res.data.dataSources || res.data || []).map((d: any) => ({
|
||||||
|
|
@ -57,7 +62,7 @@ export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
|
||||||
}, [instanceId]);
|
}, [instanceId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instanceId) return;
|
if (!instanceId) { setFeatureDataSources([]); return; }
|
||||||
api.get(`/api/workspace/${instanceId}/feature-datasources`)
|
api.get(`/api/workspace/${instanceId}/feature-datasources`)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
const list = (res.data.featureDataSources || res.data || []).map((d: any) => ({
|
const list = (res.data.featureDataSources || res.data || []).map((d: any) => ({
|
||||||
|
|
@ -99,18 +104,10 @@ export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [instanceId]);
|
}, [instanceId]);
|
||||||
|
|
||||||
if (!instanceId) {
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
||||||
<p>{t('Keine Feature-Instanz gefunden')}</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
|
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
|
||||||
<FlowEditor
|
<FlowEditor
|
||||||
instanceId={instanceId}
|
instanceId={instanceId || ''}
|
||||||
mandateId={mandateId || undefined}
|
mandateId={mandateId || undefined}
|
||||||
language={language}
|
language={language}
|
||||||
initialWorkflowId={activeWorkflowId}
|
initialWorkflowId={activeWorkflowId}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* GraphicalEditorTemplatesPage
|
* WorkflowTemplatesPage
|
||||||
*
|
*
|
||||||
* Template management with scope tabs (Meine / Instanz / Mandant / System).
|
* Template management with scope tabs (Meine / Instanz / Mandant / System).
|
||||||
* Uses FormGeneratorTable for the data list.
|
* Uses FormGeneratorTable for the data list.
|
||||||
|
|
@ -44,12 +44,12 @@ function _formatTs(ts?: number): string {
|
||||||
return time;
|
return time;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GraphicalEditorTemplatesPageProps {
|
interface WorkflowTemplatesPageProps {
|
||||||
persistentInstanceId?: string;
|
persistentInstanceId?: string;
|
||||||
persistentMandateId?: string;
|
persistentMandateId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GraphicalEditorTemplatesPage: React.FC<GraphicalEditorTemplatesPageProps> = ({
|
export const WorkflowTemplatesPage: React.FC<WorkflowTemplatesPageProps> = ({
|
||||||
persistentInstanceId,
|
persistentInstanceId,
|
||||||
persistentMandateId,
|
persistentMandateId,
|
||||||
}) => {
|
}) => {
|
||||||
|
|
@ -68,7 +68,7 @@ export const GraphicalEditorTemplatesPage: React.FC<GraphicalEditorTemplatesPage
|
||||||
const urlInstanceId = useInstanceId();
|
const urlInstanceId = useInstanceId();
|
||||||
const { mandateId: urlMandateId } = useParams<{ mandateId: string }>();
|
const { mandateId: urlMandateId } = useParams<{ mandateId: string }>();
|
||||||
const instanceId = persistentInstanceId || urlInstanceId;
|
const instanceId = persistentInstanceId || urlInstanceId;
|
||||||
const mandateId = persistentMandateId || urlMandateId;
|
const _mandateId = persistentMandateId || urlMandateId;
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
@ -87,7 +87,7 @@ export const GraphicalEditorTemplatesPage: React.FC<GraphicalEditorTemplatesPage
|
||||||
fetchAttributes(request, 'Automation2WorkflowView')
|
fetchAttributes(request, 'Automation2WorkflowView')
|
||||||
.then(setBackendAttributes)
|
.then(setBackendAttributes)
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err);
|
console.error('[workflowAutomation] fetchAttributes Automation2WorkflowView failed', err);
|
||||||
});
|
});
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
|
|
@ -96,7 +96,7 @@ export const GraphicalEditorTemplatesPage: React.FC<GraphicalEditorTemplatesPage
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const scope = activeScope === 'all' ? undefined : activeScope;
|
const scope = activeScope === 'all' ? undefined : activeScope;
|
||||||
const result = await fetchTemplates(request, instanceId, scope, paginationParams);
|
const result = await fetchTemplates(request, scope, paginationParams);
|
||||||
if (result && typeof result === 'object' && 'items' in result) {
|
if (result && typeof result === 'object' && 'items' in result) {
|
||||||
setTemplates(result.items as AutoWorkflowTemplate[]);
|
setTemplates(result.items as AutoWorkflowTemplate[]);
|
||||||
setPaginationMeta(result.pagination);
|
setPaginationMeta(result.pagination);
|
||||||
|
|
@ -105,7 +105,7 @@ export const GraphicalEditorTemplatesPage: React.FC<GraphicalEditorTemplatesPage
|
||||||
setPaginationMeta(null);
|
setPaginationMeta(null);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[graphicalEditor] load templates failed', e);
|
console.error('[workflowAutomation] load templates failed', e);
|
||||||
showError(t('Fehler beim Laden der Vorlagen'));
|
showError(t('Fehler beim Laden der Vorlagen'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -120,7 +120,7 @@ export const GraphicalEditorTemplatesPage: React.FC<GraphicalEditorTemplatesPage
|
||||||
async (templateId: string): Promise<boolean> => {
|
async (templateId: string): Promise<boolean> => {
|
||||||
if (!instanceId) return false;
|
if (!instanceId) return false;
|
||||||
try {
|
try {
|
||||||
await deleteWorkflow(request, instanceId, templateId);
|
await deleteWorkflow(request, templateId);
|
||||||
showSuccess(t('Vorlage gelöscht'));
|
showSuccess(t('Vorlage gelöscht'));
|
||||||
await load();
|
await load();
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -137,7 +137,7 @@ export const GraphicalEditorTemplatesPage: React.FC<GraphicalEditorTemplatesPage
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setCopyingId(row.id);
|
setCopyingId(row.id);
|
||||||
try {
|
try {
|
||||||
await copyTemplate(request, instanceId, row.id);
|
await copyTemplate(request, row.id);
|
||||||
showSuccess(t('Vorlage als Workflow kopiert'));
|
showSuccess(t('Vorlage als Workflow kopiert'));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
showError(t('Fehler: {msg}', { msg: e?.message || t('Kopieren fehlgeschlagen') }));
|
showError(t('Fehler: {msg}', { msg: e?.message || t('Kopieren fehlgeschlagen') }));
|
||||||
|
|
@ -153,7 +153,7 @@ export const GraphicalEditorTemplatesPage: React.FC<GraphicalEditorTemplatesPage
|
||||||
if (!instanceId) return;
|
if (!instanceId) return;
|
||||||
setSharingId(row.id);
|
setSharingId(row.id);
|
||||||
try {
|
try {
|
||||||
await shareTemplate(request, instanceId, row.id, targetScope);
|
await shareTemplate(request, row.id, targetScope);
|
||||||
showSuccess(t('Scope geändert: {scope}', { scope: scopeLabels[targetScope] }));
|
showSuccess(t('Scope geändert: {scope}', { scope: scopeLabels[targetScope] }));
|
||||||
await load();
|
await load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -177,7 +177,7 @@ export const GraphicalEditorTemplatesPage: React.FC<GraphicalEditorTemplatesPage
|
||||||
});
|
});
|
||||||
if (!newLabel || newLabel.trim() === row.label) return;
|
if (!newLabel || newLabel.trim() === row.label) return;
|
||||||
try {
|
try {
|
||||||
await updateWorkflow(request, instanceId, row.id, { label: newLabel.trim() });
|
await updateWorkflow(request, row.id, { label: newLabel.trim() });
|
||||||
showSuccess(t('Vorlage umbenannt'));
|
showSuccess(t('Vorlage umbenannt'));
|
||||||
await load();
|
await load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -189,10 +189,9 @@ export const GraphicalEditorTemplatesPage: React.FC<GraphicalEditorTemplatesPage
|
||||||
|
|
||||||
const handleEdit = useCallback(
|
const handleEdit = useCallback(
|
||||||
(row: AutoWorkflowTemplate) => {
|
(row: AutoWorkflowTemplate) => {
|
||||||
if (!mandateId || !instanceId) return;
|
navigate(`/workflow-automation?tab=editor&workflowId=${row.id}`);
|
||||||
navigate(`/mandates/${mandateId}/graphicalEditor/${instanceId}/editor?workflowId=${row.id}`);
|
|
||||||
},
|
},
|
||||||
[mandateId, instanceId, navigate]
|
[navigate]
|
||||||
);
|
);
|
||||||
|
|
||||||
const _rawColumns: ColumnConfig[] = useMemo(
|
const _rawColumns: ColumnConfig[] = useMemo(
|
||||||
|
|
@ -261,7 +260,7 @@ export const GraphicalEditorTemplatesPage: React.FC<GraphicalEditorTemplatesPage
|
||||||
filterable={true}
|
filterable={true}
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={true}
|
selectable={true}
|
||||||
apiEndpoint={`/api/workflows/${instanceId}/templates`}
|
apiEndpoint="/api/workflow-automation/templates"
|
||||||
actionButtons={[
|
actionButtons={[
|
||||||
{
|
{
|
||||||
type: 'edit',
|
type: 'edit',
|
||||||
138
src/pages/workflowAutomation/WorkflowAutomationHubPage.tsx
Normal file
138
src/pages/workflowAutomation/WorkflowAutomationHubPage.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
/**
|
||||||
|
* WorkflowAutomationHubPage
|
||||||
|
*
|
||||||
|
* System-level hub for WorkflowAutomation (user-scoped, cross-mandate).
|
||||||
|
* Tabs: Workflows · Editor · Vorlagen · Läufe · Details · Aufgaben
|
||||||
|
*
|
||||||
|
* Uses /api/workflow-automation/* endpoints (RBAC-filtered).
|
||||||
|
* Kontext-Selector: "Alle Mandanten" or a specific mandate (like BillingDataView).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { Tabs } from '../../components/UiComponents/Tabs';
|
||||||
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
|
import { useNavigation } from '../../hooks/useNavigation';
|
||||||
|
import styles from '../admin/Admin.module.css';
|
||||||
|
|
||||||
|
import { _WorkflowsTab } from './tabs/WorkflowsTab';
|
||||||
|
import { _EditorTab } from './tabs/EditorTab';
|
||||||
|
import { _TemplatesTab } from './tabs/TemplatesTab';
|
||||||
|
import { _RunsTab } from './tabs/RunsTab';
|
||||||
|
import { _RunDetailTab } from './tabs/RunDetailTab';
|
||||||
|
import { _TasksTab } from './tabs/TasksTab';
|
||||||
|
|
||||||
|
const _TAB_ALIASES: Record<string, string> = {
|
||||||
|
dashboard: 'runs',
|
||||||
|
workspace: 'detail',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WorkflowAutomationPage: React.FC = () => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { dynamicBlock } = useNavigation();
|
||||||
|
|
||||||
|
const rawTab = searchParams.get('tab') || 'workflows';
|
||||||
|
const initialTab = _TAB_ALIASES[rawTab] || rawTab;
|
||||||
|
const initialRunId = searchParams.get('runId') || null;
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<string>(initialRunId ? 'detail' : initialTab);
|
||||||
|
const [selectedRunId, setSelectedRunId] = useState<string | null>(initialRunId);
|
||||||
|
const [workflowFilter, setWorkflowFilter] = useState<string | null>(null);
|
||||||
|
const [selectedMandateId, setSelectedMandateId] = useState<string>('all');
|
||||||
|
|
||||||
|
const _mandateOptions = useMemo(() => {
|
||||||
|
const options: Array<{ value: string; label: string }> = [
|
||||||
|
{ value: 'all', label: t('Alle Mandanten') },
|
||||||
|
];
|
||||||
|
if (dynamicBlock) {
|
||||||
|
for (const mandate of dynamicBlock.mandates) {
|
||||||
|
options.push({ value: mandate.id, label: mandate.uiLabel });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}, [dynamicBlock, t]);
|
||||||
|
|
||||||
|
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} selectedMandateId={selectedMandateId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'editor',
|
||||||
|
label: t('Editor'),
|
||||||
|
content: <_EditorTab selectedMandateId={selectedMandateId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'templates',
|
||||||
|
label: t('Vorlagen'),
|
||||||
|
content: <_TemplatesTab selectedMandateId={selectedMandateId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'runs',
|
||||||
|
label: t('Workflow-Durchläufe'),
|
||||||
|
content: <_RunsTab workflowFilter={workflowFilter} onRunClick={_handleRunClick} selectedMandateId={selectedMandateId} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'detail',
|
||||||
|
label: t('Durchlauf-Details'),
|
||||||
|
content: <_RunDetailTab runId={selectedRunId} onBack={_handleBackFromWorkspace} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tasks',
|
||||||
|
label: t('Aufgaben'),
|
||||||
|
content: <_TasksTab selectedMandateId={selectedMandateId} />,
|
||||||
|
},
|
||||||
|
], [t, _handleWorkflowClick, workflowFilter, _handleRunClick, selectedRunId, _handleBackFromWorkspace, selectedMandateId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', flexShrink: 0 }}>
|
||||||
|
<h1 className={styles.pageTitle} style={{ margin: 0 }}>{t('Workflow-Automation')}</h1>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<label style={{ fontSize: 13, opacity: 0.7 }}>{t('Kontext:')}</label>
|
||||||
|
<select
|
||||||
|
value={selectedMandateId}
|
||||||
|
onChange={(e) => setSelectedMandateId(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '4px 8px',
|
||||||
|
borderRadius: 4,
|
||||||
|
border: '1px solid var(--border-color, #ccc)',
|
||||||
|
fontSize: 13,
|
||||||
|
background: 'var(--bg-primary, #fff)',
|
||||||
|
color: 'var(--text-primary, #333)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{_mandateOptions.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Tabs tabs={tabs} activeTabId={activeTab} onTabChange={setActiveTab} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { WorkflowAutomationPage as WorkflowAutomationHubPage };
|
||||||
|
export default WorkflowAutomationPage;
|
||||||
46
src/pages/workflowAutomation/tabs/EditorTab.tsx
Normal file
46
src/pages/workflowAutomation/tabs/EditorTab.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
/**
|
||||||
|
* EditorTab
|
||||||
|
*
|
||||||
|
* Wraps WorkflowEditorPage with mandate context from the hub selector.
|
||||||
|
* instanceId is resolved internally as an access gate for workspace APIs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { useNavigation } from '../../../hooks/useNavigation';
|
||||||
|
import { WorkflowEditorPage } from '../../views/workflowAutomation/WorkflowEditorPage';
|
||||||
|
import { _bestEditorInstance, _findAnyEditorInstance } from '../types';
|
||||||
|
|
||||||
|
export interface EditorTabProps {
|
||||||
|
selectedMandateId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const _EditorTab: React.FC<EditorTabProps> = ({ selectedMandateId = 'all' }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { dynamicBlock } = useNavigation();
|
||||||
|
|
||||||
|
const editorInstance = useMemo(() => {
|
||||||
|
if (selectedMandateId !== 'all') {
|
||||||
|
const inst = _bestEditorInstance(dynamicBlock, selectedMandateId);
|
||||||
|
if (inst) return { ...inst, mandateId: selectedMandateId };
|
||||||
|
}
|
||||||
|
return _findAnyEditorInstance(dynamicBlock);
|
||||||
|
}, [dynamicBlock, selectedMandateId]);
|
||||||
|
|
||||||
|
if (!editorInstance) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||||
|
<p>{t('Kein Editor verfügbar. Bitte wähle einen Mandanten mit einer Feature-Instanz.')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
|
||||||
|
<WorkflowEditorPage
|
||||||
|
persistentInstanceId={editorInstance.instanceId}
|
||||||
|
persistentMandateId={editorInstance.mandateId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
336
src/pages/workflowAutomation/tabs/RunDetailTab.tsx
Normal file
336
src/pages/workflowAutomation/tabs/RunDetailTab.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
/**
|
||||||
|
* RunDetailTab (ex _WorkspaceTab)
|
||||||
|
*
|
||||||
|
* Run detail view with step-by-step input/output inspection,
|
||||||
|
* file downloads, and automatic polling for running runs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { FaDownload } from 'react-icons/fa';
|
||||||
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import { fetchWorkspaceRunDetail } from '../../../api/workflowApi';
|
||||||
|
import api from '../../../api';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
import { _formatTs } from '../types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Utility functions
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _FILE_REF_KEYS = new Set(['fileId', 'documentId', 'fileIds', 'documents']);
|
||||||
|
|
||||||
|
function _isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||||
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _stripFileRefKeys(value: unknown): unknown {
|
||||||
|
if (_isPlainObject(value)) {
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
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 ? <p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0 }}>{emptyHint}</p> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_isPlainObject(data)) {
|
||||||
|
const entries = Object.entries(data);
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return emptyHint ? <p style={{ fontSize: '0.8rem', color: 'var(--text-secondary)', margin: 0 }}>{emptyHint}</p> : null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.25rem' }}>
|
||||||
|
{entries.map(([k, v]) => {
|
||||||
|
const isComplex = _isPlainObject(v) || Array.isArray(v);
|
||||||
|
if (isComplex) {
|
||||||
|
return (
|
||||||
|
<details key={k} style={{ fontSize: '0.8rem' }}>
|
||||||
|
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary)' }}>
|
||||||
|
<code style={{ fontWeight: 500 }}>{k}</code>
|
||||||
|
</summary>
|
||||||
|
<pre style={{ fontSize: '0.75rem', maxHeight: 240, overflow: 'auto', margin: '0.25rem 0 0 1rem', background: 'var(--bg-secondary)', padding: '0.5rem', borderRadius: 4 }}>
|
||||||
|
{JSON.stringify(v, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={k} style={{ display: 'flex', gap: '0.5rem', fontSize: '0.8rem', alignItems: 'baseline' }}>
|
||||||
|
<code style={{ color: 'var(--text-secondary)', minWidth: 140, flexShrink: 0 }}>{k}</code>
|
||||||
|
<span style={{ wordBreak: 'break-word' }}>{_formatScalar(v)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<pre style={{ fontSize: '0.75rem', maxHeight: 240, overflow: 'auto', margin: 0, background: 'var(--bg-secondary)', padding: '0.5rem', borderRadius: 4 }}>
|
||||||
|
{JSON.stringify(data, null, 2)}
|
||||||
|
</pre>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const _FileLinkList: React.FC<{ files: Array<{ id: string; fileName?: string }> }> = ({ files }) => {
|
||||||
|
if (!files.length) return null;
|
||||||
|
const baseUrl = api.defaults.baseURL || '';
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginTop: '0.25rem' }}>
|
||||||
|
{files.map((f) => (
|
||||||
|
<a
|
||||||
|
key={f.id}
|
||||||
|
href={`${baseUrl}/api/files/${f.id}/download`}
|
||||||
|
download
|
||||||
|
style={{ padding: '0.3rem 0.6rem', border: '1px solid var(--border-color)', borderRadius: 4, textDecoration: 'none', color: 'var(--primary-color)', fontSize: '0.78rem' }}
|
||||||
|
>
|
||||||
|
<FaDownload style={{ marginRight: 4 }} />
|
||||||
|
{f.fileName || f.id}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<string>();
|
||||||
|
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 (
|
||||||
|
<div style={{ marginBottom: '1rem', padding: '0.75rem', background: 'var(--surface-secondary, rgba(0,123,255,0.04))', border: '1px solid var(--border-color)', borderRadius: 8 }}>
|
||||||
|
<div style={{ fontSize: '0.82rem', fontWeight: 600, marginBottom: '0.5rem', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
<FaDownload style={{ opacity: 0.6 }} />
|
||||||
|
{t('Ergebnisse')} ({allFiles.length})
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
{allFiles.map(f => (
|
||||||
|
<a
|
||||||
|
key={f.id}
|
||||||
|
href={`${baseUrl}/api/files/${f.id}/download`}
|
||||||
|
download
|
||||||
|
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '0.35rem 0.7rem', background: 'var(--surface-primary, #fff)', border: '1px solid var(--border-color)', borderRadius: 6, textDecoration: 'none', color: 'var(--primary-color)', fontSize: '0.82rem', fontWeight: 500 }}
|
||||||
|
>
|
||||||
|
<FaDownload style={{ fontSize: '0.7rem' }} />
|
||||||
|
{f.fileName || f.id}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RunDetailTab (exported)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _TERMINAL_STATUSES = new Set(['completed', 'failed', 'cancelled', 'error', 'stopped']);
|
||||||
|
const _POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
|
export interface RunDetailTabProps {
|
||||||
|
runId: string | null;
|
||||||
|
onBack: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const _RunDetailTab: React.FC<RunDetailTabProps> = ({ runId, onBack }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [runDetail, setRunDetail] = useState<Awaited<ReturnType<typeof fetchWorkspaceRunDetail>> | 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 (
|
||||||
|
<div style={{ padding: '1rem', flex: 1, color: 'var(--text-secondary)' }}>
|
||||||
|
<p>{t('Wähle einen Run im Dashboard aus, um die Details anzuzeigen.')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailLoading || !runDetail) {
|
||||||
|
return <div style={{ padding: '1rem', flex: 1 }}><p>{t('Laden…')}</p></div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { run, steps, workflow, unassignedFiles } = runDetail;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '1rem', flex: 1, minHeight: 0, overflowY: 'auto' }}>
|
||||||
|
<button type="button" className={styles.secondaryButton} onClick={onBack} style={{ marginBottom: '1rem' }}>
|
||||||
|
← {t('Zurück zu Läufe')}
|
||||||
|
</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> {_formatTs(run.startedAt)}</span>}
|
||||||
|
{run.completedAt && <span><strong>{t('Ende')}:</strong> {_formatTs(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>
|
||||||
|
)}
|
||||||
|
<_ProducedFilesSection steps={steps} unassignedFiles={unassignedFiles} />
|
||||||
|
<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) => {
|
||||||
|
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 (
|
||||||
|
<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>}
|
||||||
|
{(step.tokensUsed ?? 0) > 0 && <span style={{ marginLeft: '0.5rem', fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{step.tokensUsed} tokens</span>}
|
||||||
|
</summary>
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginTop: '0.5rem' }}>
|
||||||
|
{hasInput && (
|
||||||
|
<section>
|
||||||
|
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{t('Input')}
|
||||||
|
{inputData !== undefined && inputData !== null && (
|
||||||
|
<button type="button" onClick={() => _downloadJson(inputData, `${step.nodeId}-input.json`)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--primary-color)', fontSize: '0.7rem', display: 'inline-flex', alignItems: 'center', gap: 3 }} title={t('Als JSON herunterladen')}>
|
||||||
|
<FaDownload /> JSON
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<_DataBlock data={inputData} />
|
||||||
|
<_FileLinkList files={inputFiles} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{hasOutput && (
|
||||||
|
<section>
|
||||||
|
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--text-secondary)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem', display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
|
{t('Output')}
|
||||||
|
{outputData !== undefined && outputData !== null && (
|
||||||
|
<button type="button" onClick={() => _downloadJson(outputData, `${step.nodeId}-output.json`)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 0, color: 'var(--primary-color)', fontSize: '0.7rem', display: 'inline-flex', alignItems: 'center', gap: 3 }} title={t('Als JSON herunterladen')}>
|
||||||
|
<FaDownload /> JSON
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<_DataBlock data={outputData} />
|
||||||
|
<_FileLinkList files={outputFiles} />
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{step.error && (
|
||||||
|
<section>
|
||||||
|
<div style={{ fontSize: '0.78rem', fontWeight: 600, color: 'var(--danger-color)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.25rem' }}>
|
||||||
|
{t('Fehler')}
|
||||||
|
</div>
|
||||||
|
<p style={{ color: 'var(--danger-color)', margin: 0, fontSize: '0.85rem' }}>{step.error}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', fontSize: '0.75rem', color: 'var(--text-secondary)', borderTop: '1px solid var(--border-color)', paddingTop: '0.4rem' }}>
|
||||||
|
{step.startedAt && <span>{t('Start')}: {_formatTs(step.startedAt)}</span>}
|
||||||
|
{step.completedAt && <span>{t('Ende')}: {_formatTs(step.completedAt)}</span>}
|
||||||
|
{(step.retryCount ?? 0) > 0 && <span>{t('Wiederholungen')}: {step.retryCount}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(() => {
|
||||||
|
const visibleUnassigned = (unassignedFiles ?? []).filter((f) => !_isHiddenWorkflowArtifactFile(f));
|
||||||
|
if (!visibleUnassigned.length) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h4 style={{ margin: '1rem 0 0.5rem' }}>{t('Sonstige Dokumente')}</h4>
|
||||||
|
<_FileLinkList files={visibleUnassigned} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
564
src/pages/workflowAutomation/tabs/RunsTab.tsx
Normal file
564
src/pages/workflowAutomation/tabs/RunsTab.tsx
Normal file
|
|
@ -0,0 +1,564 @@
|
||||||
|
/**
|
||||||
|
* RunsTab (ex _DashboardTab)
|
||||||
|
*
|
||||||
|
* Metrics overview + paginated runs table with live-tracing modal.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { FaSync, FaPlay, FaCog, FaChartBar, FaDownload, FaTimes, FaStream } from 'react-icons/fa';
|
||||||
|
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
|
||||||
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
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 styles from '../../admin/Admin.module.css';
|
||||||
|
import {
|
||||||
|
type WorkflowRunMetrics,
|
||||||
|
type WorkflowRun,
|
||||||
|
type TracingStep,
|
||||||
|
_STATUS_COLORS,
|
||||||
|
_STATUS_ICONS,
|
||||||
|
_formatTs,
|
||||||
|
_formatStepTs,
|
||||||
|
_truncateJson,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// MetricCard (local to this tab)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface MetricCardProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
color?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MetricCard: React.FC<MetricCardProps> = ({ icon, label, value, color }) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--bg-primary, #fff)',
|
||||||
|
border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 8,
|
||||||
|
padding: '16px 20px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 14,
|
||||||
|
minWidth: 180,
|
||||||
|
flex: '1 1 180px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 22, color: color || 'var(--primary-color, #007bff)', display: 'flex', alignItems: 'center' }}>
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)', marginBottom: 2 }}>{label}</div>
|
||||||
|
<div style={{ fontSize: '1.3rem', fontWeight: 700 }}>{value}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// _CollapsibleSection
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const _CollapsibleSection: React.FC<{ label: string; content: string }> = ({ label, content }) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
if (!content) return null;
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 4 }}>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); setOpen(!open); }}
|
||||||
|
style={{
|
||||||
|
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
|
||||||
|
color: 'var(--text-link, #0969da)', fontSize: 11, textDecoration: 'underline',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{open ? '▾' : '▸'} {label}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<pre style={{
|
||||||
|
margin: '4px 0 0', padding: 6, borderRadius: 4,
|
||||||
|
background: 'var(--bg-secondary, #f6f8fa)', fontSize: 11,
|
||||||
|
whiteSpace: 'pre-wrap', wordBreak: 'break-all', maxHeight: 200, overflowY: 'auto',
|
||||||
|
}}>
|
||||||
|
{content}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// _RunTracingModal (SSE-based live tracing)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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<EventSource | null>(null);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const _loadSteps = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const resp = await api.get(`/api/workflow-automation/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/workflow-automation/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 (
|
||||||
|
<div className={styles.modalOverlay}>
|
||||||
|
<div
|
||||||
|
className={styles.modal}
|
||||||
|
style={{ maxWidth: 800, height: '80vh' }}
|
||||||
|
>
|
||||||
|
<div className={styles.modalHeader}>
|
||||||
|
<div>
|
||||||
|
<h3 className={styles.modalTitle}>
|
||||||
|
{t('Run-Tracing')}: {run.workflowLabel || run.workflowId}
|
||||||
|
</h3>
|
||||||
|
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)', marginTop: 2 }}>
|
||||||
|
<span style={{ color: _STATUS_COLORS[run.status] || 'inherit', fontWeight: 600 }}>
|
||||||
|
{run.status}
|
||||||
|
</span>
|
||||||
|
{sseConnected && (
|
||||||
|
<span style={{ marginLeft: 8, color: 'var(--success-color, #28a745)' }}>● {t('Live')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className={styles.modalClose} onClick={onClose} title={t('Schliessen')}>
|
||||||
|
<FaTimes />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className={styles.modalContent} ref={scrollRef} style={{ overflowY: 'auto', flex: 1 }}>
|
||||||
|
{loading && steps.length === 0 && (
|
||||||
|
<div style={{ color: 'var(--text-secondary, #888)', fontSize: 13 }}>{t('Wird geladen…')}</div>
|
||||||
|
)}
|
||||||
|
{!loading && steps.length === 0 && (
|
||||||
|
<div style={{ color: 'var(--text-secondary, #888)', fontSize: 13 }}>{t('Noch keine Schritte aufgezeichnet')}</div>
|
||||||
|
)}
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={step.id}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px', marginBottom: 6, borderRadius: 6,
|
||||||
|
border: `1px solid ${_STATUS_COLORS[step.status] || '#ddd'}`,
|
||||||
|
background: 'var(--bg-primary, #fff)', fontSize: 13,
|
||||||
|
marginLeft: isLoop ? 16 : 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<span>
|
||||||
|
<span style={{ color: _STATUS_COLORS[step.status] || '#999', marginRight: 6 }}>
|
||||||
|
{_STATUS_ICONS[step.status] || '?'}
|
||||||
|
</span>
|
||||||
|
<strong>{step.nodeType}</strong>
|
||||||
|
<span style={{ color: '#888', marginLeft: 6 }}>({step.nodeId})</span>
|
||||||
|
{isLoop && (
|
||||||
|
<span style={{ color: '#666', marginLeft: 6, fontSize: 11 }}>
|
||||||
|
[iter {step.inputSnapshot!._loopIndex}]
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
{(step.retryCount ?? 0) > 0 && (
|
||||||
|
<span style={{ color: '#f0ad4e', fontSize: 11 }}>
|
||||||
|
{step.retryCount}x {t('Wiederholung')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{step.durationMs != null && (
|
||||||
|
<span style={{ color: '#888', fontSize: 12 }}>{step.durationMs}ms</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{(startStr || endStr) && (
|
||||||
|
<div style={{ color: '#888', fontSize: 11, marginTop: 2 }}>
|
||||||
|
{startStr && <span>{startStr}</span>}
|
||||||
|
{startStr && endStr && <span> → </span>}
|
||||||
|
{endStr && <span>{endStr}</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{step.error && (
|
||||||
|
<div style={{ color: '#dc3545', fontSize: 12, marginTop: 4 }}>{step.error}</div>
|
||||||
|
)}
|
||||||
|
{(step.tokensUsed ?? 0) > 0 && (
|
||||||
|
<div style={{ color: '#888', fontSize: 11, marginTop: 2 }}>
|
||||||
|
{step.tokensUsed} {t('Tokens')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<_CollapsibleSection label={t('Eingabe')} content={inputStr} />
|
||||||
|
<_CollapsibleSection label={t('Ausgabe')} content={outputStr} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RunsTab (exported)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface RunsTabProps {
|
||||||
|
workflowFilter?: string | null;
|
||||||
|
onRunClick?: (runId: string) => void;
|
||||||
|
selectedMandateId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const _RunsTab: React.FC<RunsTabProps> = ({ workflowFilter, onRunClick, selectedMandateId = 'all' }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const { showError } = useToast();
|
||||||
|
|
||||||
|
const [metrics, setMetrics] = useState<WorkflowRunMetrics | null>(null);
|
||||||
|
const [runs, setRuns] = useState<WorkflowRun[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||||
|
const [tracingRun, setTracingRun] = useState<WorkflowRun | null>(null);
|
||||||
|
const lastPaginationParamsRef = useRef<any>(null);
|
||||||
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes(request, 'AutoRun')
|
||||||
|
.then(setBackendAttributes)
|
||||||
|
.catch((err) => { console.error('[workflowAutomation] fetchAttributes AutoRun failed', err); });
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
const _loadMetrics = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await api.get('/api/workflow-automation/metrics');
|
||||||
|
setMetrics(resp.data);
|
||||||
|
} catch (e: any) {
|
||||||
|
const msg = e?.response?.data?.detail || e?.message || String(e);
|
||||||
|
console.error('[workflowAutomation] 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<string, any> = { pagination: JSON.stringify(pag) };
|
||||||
|
if (selectedMandateId !== 'all') params.mandateId = selectedMandateId;
|
||||||
|
const resp = await api.get('/api/workflow-automation/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('[workflowAutomation] runs load failed', e);
|
||||||
|
showError(t('Fehler beim Laden der Workflow-Runs'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedMandateId, 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/workflow-automation/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('[workflowAutomation] 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 (
|
||||||
|
<>
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.pageSubtitle}>{t('Workflow-Runs über alle Features und Mandanten')}</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => { _loadMetrics(); _loadRuns(); }} disabled={loading}>
|
||||||
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 24, flexShrink: 0 }}>
|
||||||
|
<MetricCard icon={<FaCog />} label={t('Workflows')} value={metrics?.workflowCount ?? t('—')} />
|
||||||
|
<MetricCard icon={<FaPlay />} label={t('Aktive Workflows')} value={metrics?.activeWorkflows ?? t('—')} color="var(--success-color, #28a745)" />
|
||||||
|
<MetricCard icon={<FaChartBar />} label={t('Runs gesamt')} value={metrics?.totalRuns ?? t('—')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{metrics?.runsByStatus && Object.keys(metrics.runsByStatus).length > 0 && (
|
||||||
|
<div style={{ marginBottom: 24, flexShrink: 0 }}>
|
||||||
|
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>{t('Läufe nach Status')}</h3>
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
{Object.entries(metrics.runsByStatus).map(([status, count]) => (
|
||||||
|
<span
|
||||||
|
key={status}
|
||||||
|
style={{
|
||||||
|
padding: '4px 12px',
|
||||||
|
borderRadius: 12,
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
background: 'var(--bg-secondary, #f5f5f5)',
|
||||||
|
color: _STATUS_COLORS[status] || 'inherit',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{status}: {count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && (
|
||||||
|
<div style={{ marginBottom: 24, display: 'flex', gap: 24, flexShrink: 0 }}>
|
||||||
|
{metrics.totalTokens > 0 && (
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>{t('Tokens gesamt:')} </span>
|
||||||
|
<strong>{metrics.totalTokens.toLocaleString('de-DE')}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{metrics.totalCredits > 0 && (
|
||||||
|
<div>
|
||||||
|
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>{t('Credits gesamt:')} </span>
|
||||||
|
<strong>{metrics.totalCredits.toLocaleString('de-DE', { minimumFractionDigits: 2 })}</strong>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8, flexShrink: 0 }}>
|
||||||
|
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, margin: 0 }}>{t('Letzte Runs')}</h3>
|
||||||
|
</div>
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
|
<FormGeneratorTable<WorkflowRun>
|
||||||
|
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/workflow-automation/runs"
|
||||||
|
onRowClick={(row) => onRunClick?.(row.id)}
|
||||||
|
customActions={[
|
||||||
|
{
|
||||||
|
id: 'tracing',
|
||||||
|
icon: <FaStream />,
|
||||||
|
title: t('Run-Tracing anzeigen'),
|
||||||
|
onClick: (row) => setTracingRun(row),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'download',
|
||||||
|
icon: <FaDownload />,
|
||||||
|
title: t('Tracing-Protokoll herunterladen'),
|
||||||
|
onClick: (row) => _downloadRunTracing(row),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
hookData={_hookData}
|
||||||
|
emptyMessage={t('Noch keine Workflow-Runs vorhanden.')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{tracingRun && (
|
||||||
|
<_RunTracingModal run={tracingRun} onClose={() => setTracingRun(null)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
221
src/pages/workflowAutomation/tabs/TasksTab.tsx
Normal file
221
src/pages/workflowAutomation/tabs/TasksTab.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
/**
|
||||||
|
* TasksTab
|
||||||
|
*
|
||||||
|
* Displays human tasks from /api/workflow-automation/tasks (mandate-scoped).
|
||||||
|
* Uses the new system-level API, no instanceId dependency.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
|
import { FaSync, FaCheck, FaTimes } from 'react-icons/fa';
|
||||||
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import {
|
||||||
|
fetchTasks,
|
||||||
|
completeTask,
|
||||||
|
cancelPendingTaskStopRun,
|
||||||
|
type Automation2Task,
|
||||||
|
} from '../../../api/workflowAutomationApi';
|
||||||
|
import styles from '../../admin/Admin.module.css';
|
||||||
|
import { _formatTs } from '../types';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TasksTabProps {
|
||||||
|
selectedMandateId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const _TasksTab: React.FC<TasksTabProps> = ({ selectedMandateId = 'all' }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const [tasks, setTasks] = useState<Automation2Task[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [actionId, setActionId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const _load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const data = await fetchTasks(request);
|
||||||
|
setTasks(data);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[tasks] load failed', e);
|
||||||
|
showError(t('Fehler beim Laden der Aufgaben'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [request, showError, t]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
_load();
|
||||||
|
}, [_load]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const hasPending = tasks.some((tk) => tk.status === 'pending');
|
||||||
|
if (!hasPending) return;
|
||||||
|
const interval = setInterval(() => { _load(); }, 10000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [tasks, _load]);
|
||||||
|
|
||||||
|
const _filteredTasks = useMemo(() => {
|
||||||
|
if (selectedMandateId === 'all') return tasks;
|
||||||
|
return tasks.filter((tk) => {
|
||||||
|
const cfg = tk.config as Record<string, any>;
|
||||||
|
return cfg?.mandateId === selectedMandateId;
|
||||||
|
});
|
||||||
|
}, [tasks, selectedMandateId]);
|
||||||
|
|
||||||
|
const _pendingTasks = useMemo(() => _filteredTasks.filter((tk) => tk.status === 'pending'), [_filteredTasks]);
|
||||||
|
const _completedTasks = useMemo(() => _filteredTasks.filter((tk) => tk.status !== 'pending'), [_filteredTasks]);
|
||||||
|
|
||||||
|
const _handleComplete = useCallback(async (taskId: string) => {
|
||||||
|
setActionId(taskId);
|
||||||
|
try {
|
||||||
|
await completeTask(request, taskId, {});
|
||||||
|
showSuccess(t('Aufgabe abgeschlossen'));
|
||||||
|
await _load();
|
||||||
|
} catch (e: any) {
|
||||||
|
showError(t('Fehler: {msg}', { msg: e?.message || t('Abschluss fehlgeschlagen') }));
|
||||||
|
} finally {
|
||||||
|
setActionId(null);
|
||||||
|
}
|
||||||
|
}, [request, showSuccess, showError, _load, t]);
|
||||||
|
|
||||||
|
const _handleCancel = useCallback(async (taskId: string) => {
|
||||||
|
setActionId(taskId);
|
||||||
|
try {
|
||||||
|
await cancelPendingTaskStopRun(request, taskId);
|
||||||
|
showSuccess(t('Aufgabe abgebrochen'));
|
||||||
|
await _load();
|
||||||
|
} catch (e: any) {
|
||||||
|
showError(t('Fehler: {msg}', { msg: e?.message || t('Abbruch fehlgeschlagen') }));
|
||||||
|
} finally {
|
||||||
|
setActionId(null);
|
||||||
|
}
|
||||||
|
}, [request, showSuccess, showError, _load, t]);
|
||||||
|
|
||||||
|
const [showCompleted, setShowCompleted] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.pageSubtitle}>
|
||||||
|
{t('Offene Aufgaben')}: {_pendingTasks.length}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<button className={styles.secondaryButton} onClick={_load} disabled={loading}>
|
||||||
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{_pendingTasks.length === 0 && !loading && (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||||
|
<p>{t('Keine offenen Aufgaben vorhanden.')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
{_pendingTasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
style={{
|
||||||
|
padding: '12px 16px',
|
||||||
|
border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--bg-primary, #fff)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<div style={{ fontWeight: 600, fontSize: 14 }}>
|
||||||
|
{task.workflowLabel || task.workflowId}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--text-secondary, #666)', marginTop: 2 }}>
|
||||||
|
{_nodeTypeLabel(task.nodeType, t)}
|
||||||
|
{task.createdAt ? ` · ${_formatTs(task.createdAt)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: 6, flexShrink: 0 }}>
|
||||||
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={() => _handleComplete(task.id)}
|
||||||
|
disabled={actionId === task.id}
|
||||||
|
style={{ padding: '4px 12px', fontSize: 12 }}
|
||||||
|
>
|
||||||
|
<FaCheck style={{ marginRight: 4 }} /> {t('Erledigen')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => _handleCancel(task.id)}
|
||||||
|
disabled={actionId === task.id}
|
||||||
|
style={{ padding: '4px 12px', fontSize: 12 }}
|
||||||
|
>
|
||||||
|
<FaTimes style={{ marginRight: 4 }} /> {t('Abbrechen')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{_completedTasks.length > 0 && (
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCompleted(!showCompleted)}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: 'var(--text-secondary, #666)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showCompleted ? '▾' : '▸'} {t('Abgeschlossene Aufgaben')} ({_completedTasks.length})
|
||||||
|
</button>
|
||||||
|
{showCompleted && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginTop: 8 }}>
|
||||||
|
{_completedTasks.map((task) => (
|
||||||
|
<div
|
||||||
|
key={task.id}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 8,
|
||||||
|
background: 'var(--bg-secondary, #f6f8fa)',
|
||||||
|
opacity: 0.7,
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 500 }}>{task.workflowLabel || task.workflowId}</span>
|
||||||
|
<span style={{ marginLeft: 8, color: 'var(--text-secondary)' }}>
|
||||||
|
{_nodeTypeLabel(task.nodeType, t)} · {task.status}
|
||||||
|
{task.createdAt ? ` · ${_formatTs(task.createdAt)}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
src/pages/workflowAutomation/tabs/TemplatesTab.tsx
Normal file
43
src/pages/workflowAutomation/tabs/TemplatesTab.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
/**
|
||||||
|
* TemplatesTab
|
||||||
|
*
|
||||||
|
* Wraps WorkflowTemplatesPage with mandate context from the hub selector.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
|
import { useNavigation } from '../../../hooks/useNavigation';
|
||||||
|
import { WorkflowTemplatesPage } from '../../views/workflowAutomation/WorkflowTemplatesPage';
|
||||||
|
import { _bestEditorInstance, _findAnyEditorInstance } from '../types';
|
||||||
|
|
||||||
|
export interface TemplatesTabProps {
|
||||||
|
selectedMandateId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const _TemplatesTab: React.FC<TemplatesTabProps> = ({ selectedMandateId = 'all' }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const { dynamicBlock } = useNavigation();
|
||||||
|
|
||||||
|
const editorInstance = useMemo(() => {
|
||||||
|
if (selectedMandateId !== 'all') {
|
||||||
|
const inst = _bestEditorInstance(dynamicBlock, selectedMandateId);
|
||||||
|
if (inst) return { ...inst, mandateId: selectedMandateId };
|
||||||
|
}
|
||||||
|
return _findAnyEditorInstance(dynamicBlock);
|
||||||
|
}, [dynamicBlock, selectedMandateId]);
|
||||||
|
|
||||||
|
if (!editorInstance) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '2rem', textAlign: 'center', color: 'var(--text-secondary)' }}>
|
||||||
|
<p>{t('Keine Vorlagen verfügbar. Bitte wähle einen Mandanten mit einer Feature-Instanz.')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WorkflowTemplatesPage
|
||||||
|
persistentInstanceId={editorInstance.instanceId}
|
||||||
|
persistentMandateId={editorInstance.mandateId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
400
src/pages/workflowAutomation/tabs/WorkflowsTab.tsx
Normal file
400
src/pages/workflowAutomation/tabs/WorkflowsTab.tsx
Normal file
|
|
@ -0,0 +1,400 @@
|
||||||
|
/**
|
||||||
|
* WorkflowsTab (ex _WorkflowsTab)
|
||||||
|
*
|
||||||
|
* Central workflow management across all instances.
|
||||||
|
* CRUD operations, execution, toggling, renaming.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { FaSync, FaPlay, FaCheck, FaBan, FaPen, FaEye, FaStop } from 'react-icons/fa';
|
||||||
|
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
|
||||||
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import { updateWorkflow, executeGraph, deleteSystemWorkflow } 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 styles from '../../admin/Admin.module.css';
|
||||||
|
import {
|
||||||
|
type SystemWorkflow,
|
||||||
|
_formatTs,
|
||||||
|
} from '../types';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// WorkflowsTab (exported)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface WorkflowsTabProps {
|
||||||
|
onWorkflowClick?: (workflowId: string) => void;
|
||||||
|
selectedMandateId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const _WorkflowsTab: React.FC<WorkflowsTabProps> = ({ onWorkflowClick, selectedMandateId = 'all' }) => {
|
||||||
|
const { t } = useLanguage();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { prompt: promptInput, PromptDialog } = usePrompt();
|
||||||
|
const [workflows, setWorkflows] = useState<SystemWorkflow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [executingId, setExecutingId] = useState<string | null>(null);
|
||||||
|
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||||
|
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
||||||
|
const [paginationMeta, setPaginationMeta] = useState<any>(null);
|
||||||
|
const lastPaginationParamsRef = useRef<any>(null);
|
||||||
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchAttributes(request, 'Automation2WorkflowView')
|
||||||
|
.then(setBackendAttributes)
|
||||||
|
.catch((err) => { console.error('[workflowAutomation] 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<string, any> = {};
|
||||||
|
if (activeFilter === 'active') params.active = true;
|
||||||
|
if (activeFilter === 'inactive') params.active = false;
|
||||||
|
if (selectedMandateId !== 'all') params.mandateId = selectedMandateId;
|
||||||
|
|
||||||
|
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/workflow-automation/workflows', { params });
|
||||||
|
const data = resp.data;
|
||||||
|
setWorkflows(data?.items || []);
|
||||||
|
setPaginationMeta(data?.pagination || null);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[workflowAutomation] load system workflows failed', e);
|
||||||
|
showError(t('Fehler beim Laden der Workflows'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [activeFilter, selectedMandateId, 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) => {
|
||||||
|
navigate(`/workflow-automation?tab=editor&workflowId=${row.id}`);
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const _handleDelete = useCallback(async (workflowId: string): Promise<boolean> => {
|
||||||
|
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.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.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, 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<string | null>(null);
|
||||||
|
|
||||||
|
const _handleStop = useCallback(async (row: SystemWorkflow) => {
|
||||||
|
if (!row.activeRunId) return;
|
||||||
|
setStoppingId(row.id);
|
||||||
|
try {
|
||||||
|
await api.post(`/api/workflow-automation/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 (
|
||||||
|
<>
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.pageSubtitle}>
|
||||||
|
{t('Alle Workflows über alle Features und Mandanten')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerActions} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<div style={{ display: 'flex', gap: 4 }}>
|
||||||
|
{(['all', 'active', 'inactive'] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
className={activeFilter === f ? styles.primaryButton : styles.secondaryButton}
|
||||||
|
onClick={() => setActiveFilter(f)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{f === 'all' ? t('Alle') : f === 'active' ? t('Aktiv') : t('Inaktiv')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className={styles.secondaryButton} onClick={() => _load()} disabled={loading}>
|
||||||
|
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
|
<FormGeneratorTable<SystemWorkflow>
|
||||||
|
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/workflow-automation/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: <FaEye />,
|
||||||
|
title: t('anzeigen'),
|
||||||
|
onClick: (row) => _handleEdit(row),
|
||||||
|
visible: (row) => row.canEdit !== true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rename',
|
||||||
|
icon: <FaPen />,
|
||||||
|
title: t('umbenennen'),
|
||||||
|
onClick: (row) => _handleRename(row),
|
||||||
|
visible: (row) => row.canEdit === true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'activate',
|
||||||
|
icon: <FaCheck />,
|
||||||
|
title: t('aktivieren'),
|
||||||
|
onClick: (row) => _handleToggleActive(row),
|
||||||
|
loading: (row) => togglingId === row.id,
|
||||||
|
visible: (row) => row.canEdit === true && row.active === false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deactivate',
|
||||||
|
icon: <FaBan />,
|
||||||
|
title: t('deaktivieren'),
|
||||||
|
onClick: (row) => _handleToggleActive(row),
|
||||||
|
loading: (row) => togglingId === row.id,
|
||||||
|
visible: (row) => row.canEdit === true && row.active !== false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'execute',
|
||||||
|
icon: <FaPlay />,
|
||||||
|
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: <FaStop />,
|
||||||
|
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.')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<PromptDialog />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
184
src/pages/workflowAutomation/types.ts
Normal file
184
src/pages/workflowAutomation/types.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
/**
|
||||||
|
* workflowAutomation/types.ts
|
||||||
|
*
|
||||||
|
* Shared interfaces, constants, and helper functions
|
||||||
|
* used across all WorkflowAutomation tabs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { formatUnixTimestamp } from '../../utils/time';
|
||||||
|
import type { DynamicBlock } from '../../hooks/useNavigation';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared interfaces
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface WorkflowRunMetrics {
|
||||||
|
totalRuns: number;
|
||||||
|
runsByStatus: Record<string, number>;
|
||||||
|
totalTokens: number;
|
||||||
|
totalCredits: number;
|
||||||
|
workflowCount: number;
|
||||||
|
activeWorkflows: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TracingStep {
|
||||||
|
id: string;
|
||||||
|
nodeId: string;
|
||||||
|
nodeType: string;
|
||||||
|
status: string;
|
||||||
|
startedAt?: number;
|
||||||
|
completedAt?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
error?: string;
|
||||||
|
tokensUsed?: number;
|
||||||
|
inputSnapshot?: Record<string, any>;
|
||||||
|
output?: Record<string, any>;
|
||||||
|
retryCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared constants
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const _STATUS_COLORS: Record<string, string> = {
|
||||||
|
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)',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const _STATUS_ICONS: Record<string, string> = {
|
||||||
|
pending: '○', running: '◉', completed: '✓', failed: '✗', stopped: '■', skipped: '—',
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Editor instance resolution (used by tabs + hub page)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const _FEATURES_WITH_EDITOR = new Set(['workspace']);
|
||||||
|
|
||||||
|
export const _ROLE_PRIORITY: Record<string, number> = { admin: 3, user: 2, viewer: 1 };
|
||||||
|
|
||||||
|
export 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. */
|
||||||
|
export 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;
|
||||||
|
}
|
||||||
|
|
@ -52,7 +52,7 @@
|
||||||
--bg-primary: #ffffff;
|
--bg-primary: #ffffff;
|
||||||
--bg-secondary: #F7FAFC;
|
--bg-secondary: #F7FAFC;
|
||||||
--bg-dark: #EDF2F7;
|
--bg-dark: #EDF2F7;
|
||||||
/* Canvas surface for the GraphicalEditor flow editor (slightly off-white). */
|
/* Canvas surface for the WorkflowAutomation flow editor (slightly off-white). */
|
||||||
--canvas-bg: #FAFAFA;
|
--canvas-bg: #FAFAFA;
|
||||||
/* Dot color of the infinite background grid on the editor canvas. */
|
/* Dot color of the infinite background grid on the editor canvas. */
|
||||||
--canvas-grid: #D9DEE5;
|
--canvas-grid: #D9DEE5;
|
||||||
|
|
@ -130,7 +130,7 @@
|
||||||
--bg-primary: #1A202C;
|
--bg-primary: #1A202C;
|
||||||
--bg-secondary: #2D3748;
|
--bg-secondary: #2D3748;
|
||||||
--bg-dark: #171923;
|
--bg-dark: #171923;
|
||||||
/* Canvas surface for the GraphicalEditor flow editor — slightly darker than
|
/* Canvas surface for the WorkflowAutomation flow editor — slightly darker than
|
||||||
--bg-primary so nodes and connection lines stay legible against it. */
|
--bg-primary so nodes and connection lines stay legible against it. */
|
||||||
--canvas-bg: #131820;
|
--canvas-bg: #131820;
|
||||||
/* Dot color of the infinite background grid — bright enough to read on
|
/* Dot color of the infinite background grid — bright enough to read on
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue