first version graphic automation editor
This commit is contained in:
parent
196f76f95e
commit
9b4bad975c
14 changed files with 2879 additions and 3 deletions
|
|
@ -154,8 +154,10 @@ function App() {
|
|||
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
||||
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
||||
|
||||
{/* Workspace Editor */}
|
||||
{/* Workspace + Automation2 Editor */}
|
||||
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
||||
{/* Automation2 Workflows & Tasks */}
|
||||
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
|
||||
|
||||
{/* Teams Bot Feature Views */}
|
||||
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
||||
|
|
|
|||
271
src/api/automation2Api.ts
Normal file
271
src/api/automation2Api.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
/**
|
||||
* Automation2 API
|
||||
* Node types and graph execution for n8n-style flows.
|
||||
*/
|
||||
|
||||
import type { ApiRequestOptions } from '../hooks/useApi';
|
||||
|
||||
const LOG = '[Automation2]';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
// ============================================================================
|
||||
|
||||
export interface NodeTypeParameter {
|
||||
name: string;
|
||||
type: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
export interface NodeType {
|
||||
id: string;
|
||||
category: string;
|
||||
label: string;
|
||||
description: string;
|
||||
parameters: NodeTypeParameter[];
|
||||
inputs: number;
|
||||
outputs: number;
|
||||
executor: string;
|
||||
meta?: {
|
||||
icon?: string;
|
||||
color?: string;
|
||||
method?: string;
|
||||
action?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NodeTypeCategory {
|
||||
id: string;
|
||||
label: Record<string, string> | string;
|
||||
}
|
||||
|
||||
export interface NodeTypesResponse {
|
||||
nodeTypes: NodeType[];
|
||||
categories: NodeTypeCategory[];
|
||||
}
|
||||
|
||||
export interface Automation2GraphNode {
|
||||
id: string;
|
||||
type: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Automation2Connection {
|
||||
source: string;
|
||||
target: string;
|
||||
sourceOutput?: number;
|
||||
targetInput?: number;
|
||||
}
|
||||
|
||||
export interface Automation2Graph {
|
||||
nodes: Automation2GraphNode[];
|
||||
connections: Automation2Connection[];
|
||||
}
|
||||
|
||||
export interface ExecuteGraphResponse {
|
||||
success: boolean;
|
||||
nodeOutputs?: Record<string, unknown>;
|
||||
error?: string;
|
||||
stopped?: boolean;
|
||||
failedNode?: string;
|
||||
paused?: boolean;
|
||||
taskId?: string;
|
||||
runId?: string;
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
export interface Automation2Workflow {
|
||||
id: string;
|
||||
label: string;
|
||||
graph: Automation2Graph;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// API FUNCTIONS
|
||||
// ============================================================================
|
||||
|
||||
export type ApiRequestFunction = (options: ApiRequestOptions) => Promise<any>;
|
||||
|
||||
/**
|
||||
* Fetch node types for the flow builder (backend-driven).
|
||||
* GET /api/automation2/{instanceId}/node-types?language=de
|
||||
*/
|
||||
export async function fetchNodeTypes(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
language = 'de'
|
||||
): Promise<NodeTypesResponse> {
|
||||
console.log(`${LOG} fetchNodeTypes: instanceId=${instanceId} language=${language}`);
|
||||
const data = await request({
|
||||
url: `/api/automation2/${instanceId}/node-types`,
|
||||
method: 'get',
|
||||
params: { language },
|
||||
});
|
||||
const nodeTypes = data?.nodeTypes ?? [];
|
||||
const categories = data?.categories ?? [];
|
||||
console.log(`${LOG} fetchNodeTypes response: ${nodeTypes.length} nodeTypes, ${categories.length} categories`);
|
||||
return { nodeTypes, categories };
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an automation2 graph.
|
||||
* POST /api/automation2/{instanceId}/execute
|
||||
*/
|
||||
export async function executeGraph(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
graph: Automation2Graph,
|
||||
workflowId?: string
|
||||
): Promise<ExecuteGraphResponse> {
|
||||
console.log(
|
||||
`${LOG} executeGraph request: instanceId=${instanceId} workflowId=${workflowId} nodes=${graph.nodes.length} connections=${graph.connections.length}`,
|
||||
{ nodes: graph.nodes, connections: graph.connections }
|
||||
);
|
||||
const start = performance.now();
|
||||
try {
|
||||
const result = await request({
|
||||
url: `/api/automation2/${instanceId}/execute`,
|
||||
method: 'post',
|
||||
data: { graph, workflowId },
|
||||
});
|
||||
const ms = Math.round(performance.now() - start);
|
||||
console.log(
|
||||
`${LOG} executeGraph response (${ms}ms): success=${result?.success} error=${result?.error ?? 'none'} nodeOutputs_keys=${Object.keys(result?.nodeOutputs ?? {}).join(',')} failedNode=${result?.failedNode ?? '-'}`,
|
||||
result
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
const ms = Math.round(performance.now() - start);
|
||||
console.error(
|
||||
`${LOG} executeGraph FAILED (${ms}ms): instanceId=${instanceId}`,
|
||||
err
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Workflows CRUD
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
export async function fetchWorkflows(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string
|
||||
): Promise<Automation2Workflow[]> {
|
||||
const data = await request({
|
||||
url: `/api/automation2/${instanceId}/workflows`,
|
||||
method: 'get',
|
||||
});
|
||||
return data?.workflows ?? [];
|
||||
}
|
||||
|
||||
export async function fetchWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<Automation2Workflow> {
|
||||
return await request({
|
||||
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
|
||||
method: 'get',
|
||||
});
|
||||
}
|
||||
|
||||
export async function createWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
body: { label: string; graph: Automation2Graph }
|
||||
): Promise<Automation2Workflow> {
|
||||
return await request({
|
||||
url: `/api/automation2/${instanceId}/workflows`,
|
||||
method: 'post',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string,
|
||||
body: { label?: string; graph?: Automation2Graph }
|
||||
): Promise<Automation2Workflow> {
|
||||
return await request({
|
||||
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
|
||||
method: 'put',
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWorkflow(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<void> {
|
||||
await request({
|
||||
url: `/api/automation2/${instanceId}/workflows/${workflowId}`,
|
||||
method: 'delete',
|
||||
});
|
||||
}
|
||||
|
||||
export interface Automation2Run {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
status: string;
|
||||
nodeOutputs?: Record<string, unknown>;
|
||||
currentNodeId?: string;
|
||||
}
|
||||
|
||||
export async function fetchWorkflowRuns(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
workflowId: string
|
||||
): Promise<Automation2Run[]> {
|
||||
const data = await request({
|
||||
url: `/api/automation2/${instanceId}/workflows/${workflowId}/runs`,
|
||||
method: 'get',
|
||||
});
|
||||
return data?.runs ?? [];
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Tasks
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
export interface Automation2Task {
|
||||
id: string;
|
||||
runId: string;
|
||||
workflowId: string;
|
||||
nodeId: string;
|
||||
nodeType: string;
|
||||
config: Record<string, unknown>;
|
||||
status: string;
|
||||
result?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchTasks(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
params?: { workflowId?: string; status?: string }
|
||||
): Promise<Automation2Task[]> {
|
||||
const data = await request({
|
||||
url: `/api/automation2/${instanceId}/tasks`,
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
return data?.tasks ?? [];
|
||||
}
|
||||
|
||||
export async function completeTask(
|
||||
request: ApiRequestFunction,
|
||||
instanceId: string,
|
||||
taskId: string,
|
||||
result: Record<string, unknown>
|
||||
): Promise<ExecuteGraphResponse> {
|
||||
return await request({
|
||||
url: `/api/automation2/${instanceId}/tasks/${taskId}/complete`,
|
||||
method: 'post',
|
||||
data: { result },
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,465 @@
|
|||
/**
|
||||
* Automation2 Flow Editor Styles
|
||||
* Sidebar with node list + canvas area.
|
||||
*/
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
SIDEBAR - Node List
|
||||
============================================================================= */
|
||||
|
||||
.sidebar {
|
||||
flex-shrink: 0;
|
||||
width: 280px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
border-right: 1px solid var(--border-color, #e0e0e0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebarHeader {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.sidebarTitle {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.sidebarSearch {
|
||||
margin-top: 0.75rem;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg-primary, #fff);
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.sidebarSearch::placeholder {
|
||||
color: var(--text-tertiary, #999);
|
||||
}
|
||||
|
||||
.sidebarSearch:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #007bff);
|
||||
box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.15);
|
||||
}
|
||||
|
||||
.nodeList {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
/* Category Groups */
|
||||
.categoryGroup {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
|
||||
.categoryHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.categoryIcon {
|
||||
margin-right: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.categoryLabel {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.categoryCount {
|
||||
background: var(--bg-tertiary, #e9ecef);
|
||||
color: var(--text-secondary, #666);
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
/* Node Items */
|
||||
.nodeItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.25rem;
|
||||
border-radius: 6px;
|
||||
cursor: grab;
|
||||
transition: background 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.nodeItem:hover {
|
||||
background: var(--bg-hover, #e9ecef);
|
||||
}
|
||||
|
||||
.nodeItem:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.nodeItemIcon {
|
||||
flex-shrink: 0;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
margin-right: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.nodeItemInfo {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodeItemLabel {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #333);
|
||||
}
|
||||
|
||||
.nodeItemDesc {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Loading / Error */
|
||||
.loading,
|
||||
.error {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: var(--danger-color, #dc3545);
|
||||
}
|
||||
|
||||
.retryButton {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.retryButton:hover {
|
||||
background: var(--primary-hover, #0056b3);
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
CANVAS
|
||||
============================================================================= */
|
||||
|
||||
.canvas {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
background: var(--canvas-bg, #fafafa);
|
||||
}
|
||||
|
||||
.canvasHeader {
|
||||
flex-shrink: 0;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
background: var(--bg-primary, #fff);
|
||||
}
|
||||
|
||||
.canvasTitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.canvasArea {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
min-height: 400px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.canvasDropZone {
|
||||
position: relative;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-image: radial-gradient(circle, var(--border-color, #e0e0e0) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.canvasPlaceholder {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: var(--text-tertiary, #999);
|
||||
border: 2px dashed var(--border-color, #dee2e6);
|
||||
border-radius: 8px;
|
||||
padding: 2rem 3rem;
|
||||
}
|
||||
|
||||
.canvasPlaceholder p {
|
||||
margin: 0.25rem 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Canvas Nodes */
|
||||
.canvasNode {
|
||||
position: absolute;
|
||||
border-radius: 8px;
|
||||
border: 2px solid;
|
||||
cursor: grab;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.canvasNode:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.canvasNodeSelected {
|
||||
box-shadow: 0 0 0 2px var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.canvasNodeContent {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.canvasNodeIcon {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.canvasNodeText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.canvasNodeTitle {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #333);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.canvasNodeTitle:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.canvasNodeComment {
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-tertiary, #999);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.canvasNodeComment:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.canvasNodeInput {
|
||||
width: 100%;
|
||||
padding: 0.15rem 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--primary-color, #007bff);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Connection Handles */
|
||||
.handle {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-primary, #fff);
|
||||
border: 2px solid var(--border-color, #666);
|
||||
cursor: crosshair;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.handle:hover,
|
||||
.handleConnectable {
|
||||
border-color: var(--primary-color, #007bff);
|
||||
background: var(--primary-color, #007bff);
|
||||
}
|
||||
|
||||
.handleInput {
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
/* Node Config Panel */
|
||||
.nodeConfigPanel {
|
||||
padding: 1rem;
|
||||
background: var(--bg-primary, #fff);
|
||||
border-left: 1px solid var(--border-color, #e0e0e0);
|
||||
width: 280px;
|
||||
flex-shrink: 0;
|
||||
overflow-y: auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.nodeConfigPanel h4 {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nodeConfigPanel label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.nodeConfigPanel input[type='text'],
|
||||
.nodeConfigPanel input[type='number'],
|
||||
.nodeConfigPanel select,
|
||||
.nodeConfigPanel textarea {
|
||||
width: 100%;
|
||||
padding: 0.4rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.nodeConfigPanel textarea {
|
||||
min-height: 60px;
|
||||
}
|
||||
|
||||
.nodeConfigPanel button {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
background: var(--primary-color, #007bff);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Form fields editor (input.form) */
|
||||
.formFieldsList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.formFieldRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--bg-secondary, #f8f9fa);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
}
|
||||
|
||||
.formFieldRowHeader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.formFieldDragHandle {
|
||||
flex-shrink: 0;
|
||||
padding: 0.25rem;
|
||||
cursor: grab;
|
||||
color: var(--text-tertiary, #999);
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.formFieldDragHandle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.formFieldDragHandle:hover {
|
||||
color: var(--text-secondary, #666);
|
||||
}
|
||||
|
||||
.formFieldInputs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.formFieldRowFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.formFieldRequiredLabel {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
cursor: pointer;
|
||||
}
|
||||
676
src/components/Automation2FlowEditor/Automation2FlowEditor.tsx
Normal file
676
src/components/Automation2FlowEditor/Automation2FlowEditor.tsx
Normal file
|
|
@ -0,0 +1,676 @@
|
|||
/**
|
||||
* Automation2FlowEditor
|
||||
*
|
||||
* n8n-style flow builder with backend-driven node list.
|
||||
* Sidebar: all available node types (from API), grouped by category.
|
||||
* Canvas: placeholder for graph (drag nodes to add).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
FaPlay,
|
||||
FaCodeBranch,
|
||||
FaDatabase,
|
||||
FaPlug,
|
||||
FaUser,
|
||||
FaSignInAlt,
|
||||
FaSpinner,
|
||||
FaChevronDown,
|
||||
FaChevronRight,
|
||||
} from 'react-icons/fa';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import {
|
||||
fetchNodeTypes,
|
||||
executeGraph,
|
||||
fetchWorkflows,
|
||||
fetchWorkflow,
|
||||
createWorkflow,
|
||||
updateWorkflow,
|
||||
type NodeType,
|
||||
type NodeTypeCategory,
|
||||
type Automation2Graph,
|
||||
type Automation2Workflow,
|
||||
type ExecuteGraphResponse,
|
||||
} from '../../api/automation2Api';
|
||||
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
||||
import { NodeConfigPanel } from './NodeConfigPanel';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
// Map category -> icon
|
||||
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||
trigger: <FaPlay />,
|
||||
input: <FaUser />,
|
||||
flow: <FaCodeBranch />,
|
||||
data: <FaDatabase />,
|
||||
io: <FaPlug />,
|
||||
human: <FaUser />,
|
||||
};
|
||||
|
||||
// I/O nodes: group by method/context (KI, Kontext, Outlook, SharePoint, Jira, Trustee, Chatbot)
|
||||
const IO_METHOD_ORDER = ['ai', 'context', 'outlook', 'sharepoint', 'jira', 'trustee', 'chatbot'];
|
||||
const IO_METHOD_LABELS: Record<string, Record<string, string>> = {
|
||||
ai: { de: 'KI', en: 'AI', fr: 'IA' },
|
||||
context: { de: 'Kontext', en: 'Context', fr: 'Contexte' },
|
||||
outlook: { de: 'Outlook', en: 'Outlook', fr: 'Outlook' },
|
||||
sharepoint: { de: 'SharePoint', en: 'SharePoint', fr: 'SharePoint' },
|
||||
jira: { de: 'Jira', en: 'Jira', fr: 'Jira' },
|
||||
trustee: { de: 'Trustee', en: 'Trustee', fr: 'Trustee' },
|
||||
chatbot: { de: 'Chatbot', en: 'Chatbot', fr: 'Chatbot' },
|
||||
};
|
||||
|
||||
function getCategoryIcon(categoryId: string): React.ReactNode {
|
||||
return CATEGORY_ICONS[categoryId] ?? <FaPlug />;
|
||||
}
|
||||
|
||||
function getIoMethodLabel(method: string, lang: string): string {
|
||||
return IO_METHOD_LABELS[method]?.[lang] ?? IO_METHOD_LABELS[method]?.en ?? method;
|
||||
}
|
||||
|
||||
const LOG = '[Automation2]';
|
||||
|
||||
function getLabel(text: string | Record<string, string> | undefined, lang = 'de'): string {
|
||||
if (!text) return '';
|
||||
if (typeof text === 'string') return text;
|
||||
return (text as Record<string, string>)[lang] ?? (text as Record<string, string>).en ?? '';
|
||||
}
|
||||
|
||||
interface Automation2FlowEditorProps {
|
||||
instanceId: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||
instanceId,
|
||||
language = 'de',
|
||||
}) => {
|
||||
const { request } = useApiRequest();
|
||||
const [nodeTypes, setNodeTypes] = useState<NodeType[]>([]);
|
||||
const [categories, setCategories] = useState<NodeTypeCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||
new Set(['trigger', 'input', 'flow', 'data'])
|
||||
);
|
||||
const [expandedIoMethods, setExpandedIoMethods] = useState<Set<string>>(new Set());
|
||||
const [canvasNodes, setCanvasNodes] = useState<CanvasNode[]>([]);
|
||||
const [canvasConnections, setCanvasConnections] = useState<CanvasConnection[]>([]);
|
||||
const [executing, setExecuting] = useState(false);
|
||||
const [executeResult, setExecuteResult] = useState<ExecuteGraphResponse | null>(null);
|
||||
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
|
||||
const [currentWorkflowId, setCurrentWorkflowId] = useState<string | null>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<CanvasNode | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fromApiGraph = useCallback((graph: Automation2Graph): { nodes: CanvasNode[]; connections: CanvasConnection[] } => {
|
||||
const nodeMap = new Map<string, { inputs: number; outputs: number }>();
|
||||
nodeTypes.forEach((nt) => {
|
||||
nodeMap.set(nt.id, { inputs: nt.inputs ?? 1, outputs: nt.outputs ?? 1 });
|
||||
});
|
||||
const nodes: CanvasNode[] = (graph.nodes || []).map((n) => {
|
||||
const io = nodeMap.get(n.type) ?? { inputs: 1, outputs: 1 };
|
||||
return {
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
x: (n as { x?: number }).x ?? 0,
|
||||
y: (n as { y?: number }).y ?? 0,
|
||||
title: (n as { title?: string }).title ?? (typeof n.type === 'string' ? n.type : ''),
|
||||
comment: (n as { comment?: string }).comment,
|
||||
inputs: io.inputs,
|
||||
outputs: io.outputs,
|
||||
parameters: n.parameters ?? {},
|
||||
};
|
||||
});
|
||||
const connId = (s: string, t: string, so: number, ti: number) => `c_${s}_${so}_${t}_${ti}`;
|
||||
const connections: CanvasConnection[] = (graph.connections || []).map((c) => {
|
||||
const srcNode = nodes.find((n) => n.id === c.source);
|
||||
const sourceOutput = c.sourceOutput ?? 0;
|
||||
const sourceHandle = srcNode ? srcNode.inputs + sourceOutput : 0;
|
||||
return {
|
||||
id: connId(c.source, c.target, sourceOutput, c.targetInput ?? 0),
|
||||
sourceId: c.source,
|
||||
sourceHandle,
|
||||
targetId: c.target,
|
||||
targetHandle: c.targetInput ?? 0,
|
||||
};
|
||||
});
|
||||
return { nodes, connections };
|
||||
}, [nodeTypes]);
|
||||
|
||||
const toApiGraph = useCallback((): Automation2Graph => {
|
||||
const nodeMap = new Map(canvasNodes.map((n) => [n.id, n]));
|
||||
const graph = {
|
||||
nodes: canvasNodes.map((n) => ({
|
||||
id: n.id,
|
||||
type: n.type,
|
||||
x: n.x,
|
||||
y: n.y,
|
||||
title: n.title,
|
||||
comment: n.comment,
|
||||
parameters: n.parameters ?? {},
|
||||
})),
|
||||
connections: canvasConnections.map((c) => {
|
||||
const srcNode = nodeMap.get(c.sourceId);
|
||||
const sourceOutput =
|
||||
srcNode && c.sourceHandle >= srcNode.inputs ? c.sourceHandle - srcNode.inputs : 0;
|
||||
return {
|
||||
source: c.sourceId,
|
||||
target: c.targetId,
|
||||
sourceOutput,
|
||||
targetInput: c.targetHandle,
|
||||
};
|
||||
}),
|
||||
};
|
||||
console.log(`${LOG} toApiGraph: canvasNodes=${canvasNodes.length} canvasConnections=${canvasConnections.length} ->`, graph);
|
||||
return graph;
|
||||
}, [canvasNodes, canvasConnections]);
|
||||
|
||||
const handleExecute = useCallback(async () => {
|
||||
console.log(`${LOG} handleExecute: start`);
|
||||
const graph = toApiGraph();
|
||||
if (graph.nodes.length === 0) {
|
||||
console.warn(`${LOG} handleExecute: keine Nodes, abbrechen`);
|
||||
setExecuteResult({ success: false, error: 'Keine Nodes im Workflow.' });
|
||||
return;
|
||||
}
|
||||
setExecuting(true);
|
||||
setExecuteResult(null);
|
||||
console.log(`${LOG} handleExecute: rufe executeGraph auf instanceId=${instanceId}`);
|
||||
try {
|
||||
const result = await executeGraph(request, instanceId, graph, currentWorkflowId ?? undefined);
|
||||
console.log(`${LOG} handleExecute: fertig success=${result?.success}`, result);
|
||||
setExecuteResult(result);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`${LOG} handleExecute: Fehler`, err);
|
||||
setExecuteResult({ success: false, error: msg });
|
||||
} finally {
|
||||
setExecuting(false);
|
||||
console.log(`${LOG} handleExecute: end`);
|
||||
}
|
||||
}, [request, instanceId, toApiGraph, currentWorkflowId]);
|
||||
|
||||
const loadWorkflows = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
try {
|
||||
const items = await fetchWorkflows(request, instanceId);
|
||||
setWorkflows(items);
|
||||
} catch (e) {
|
||||
console.error(`${LOG} loadWorkflows failed`, e);
|
||||
}
|
||||
}, [instanceId, request]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const graph = toApiGraph();
|
||||
if (graph.nodes.length === 0) {
|
||||
setExecuteResult({ success: false, error: 'Keine Nodes zum Speichern.' });
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (currentWorkflowId) {
|
||||
await updateWorkflow(request, instanceId, currentWorkflowId, { graph });
|
||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||
} else {
|
||||
const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow';
|
||||
const created = await createWorkflow(request, instanceId, { label, graph });
|
||||
setCurrentWorkflowId(created.id);
|
||||
setWorkflows((prev) => [...prev, created]);
|
||||
setExecuteResult({ success: true } as ExecuteGraphResponse);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setExecuteResult({ success: false, error: msg });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [request, instanceId, toApiGraph, currentWorkflowId]);
|
||||
|
||||
const handleLoad = useCallback(async (workflowId: string) => {
|
||||
try {
|
||||
const wf = await fetchWorkflow(request, instanceId, workflowId);
|
||||
const graph = wf.graph;
|
||||
if (graph) {
|
||||
const { nodes, connections } = fromApiGraph(graph);
|
||||
setCanvasNodes(nodes);
|
||||
setCanvasConnections(connections);
|
||||
setCurrentWorkflowId(wf.id);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
setExecuteResult({ success: false, error: msg });
|
||||
}
|
||||
}, [request, instanceId, fromApiGraph]);
|
||||
|
||||
const handleNew = useCallback(() => {
|
||||
setCanvasNodes([]);
|
||||
setCanvasConnections([]);
|
||||
setCurrentWorkflowId(null);
|
||||
setExecuteResult(null);
|
||||
}, []);
|
||||
|
||||
const handleNodeParametersChange = useCallback((nodeId: string, parameters: Record<string, unknown>) => {
|
||||
setCanvasNodes((prev) => prev.map((n) => (n.id === nodeId ? { ...n, parameters } : n)));
|
||||
}, []);
|
||||
|
||||
const loadNodeTypes = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
console.log(`${LOG} loadNodeTypes: start instanceId=${instanceId} language=${language}`);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchNodeTypes(request, instanceId, language);
|
||||
setNodeTypes(data.nodeTypes);
|
||||
setCategories(data.categories);
|
||||
console.log(`${LOG} loadNodeTypes: ok ${data.nodeTypes.length} types, ${data.categories.length} categories`);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`${LOG} loadNodeTypes: Fehler`, err);
|
||||
setError(msg);
|
||||
setNodeTypes([]);
|
||||
setCategories([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
console.log(`${LOG} loadNodeTypes: end`);
|
||||
}
|
||||
}, [instanceId, language, request]);
|
||||
|
||||
useEffect(() => {
|
||||
loadNodeTypes();
|
||||
}, [loadNodeTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
loadWorkflows();
|
||||
}, [loadWorkflows]);
|
||||
|
||||
const toggleCategory = (id: string) => {
|
||||
setExpandedCategories((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleIoMethod = (method: string) => {
|
||||
setExpandedIoMethods((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(method)) next.delete(method);
|
||||
else next.add(method);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const filteredNodeTypes = useMemo(() => {
|
||||
if (!filter.trim()) return nodeTypes;
|
||||
const q = filter.toLowerCase();
|
||||
return nodeTypes.filter(
|
||||
(n) =>
|
||||
n.id.toLowerCase().includes(q) ||
|
||||
getLabel(n.label, language).toLowerCase().includes(q) ||
|
||||
getLabel(n.description, language).toLowerCase().includes(q)
|
||||
);
|
||||
}, [nodeTypes, filter, language]);
|
||||
|
||||
const groupedByCategory = useMemo(() => {
|
||||
const map: Record<string, NodeType[]> = {};
|
||||
filteredNodeTypes.forEach((n) => {
|
||||
const cat = n.category || 'other';
|
||||
if (!map[cat]) map[cat] = [];
|
||||
map[cat].push(n);
|
||||
});
|
||||
return map;
|
||||
}, [filteredNodeTypes]);
|
||||
|
||||
// For io category: sub-group by method (KI, Kontext, Outlook, etc.)
|
||||
const ioSubGroups = useMemo(() => {
|
||||
const ioNodes = groupedByCategory['io'] || [];
|
||||
const byMethod: Record<string, NodeType[]> = {};
|
||||
ioNodes.forEach((n) => {
|
||||
const method = n.meta?.method ?? n.id.split('.')[1] ?? 'other';
|
||||
if (!byMethod[method]) byMethod[method] = [];
|
||||
byMethod[method].push(n);
|
||||
});
|
||||
const ordered: Array<{ method: string; nodes: NodeType[] }> = [];
|
||||
IO_METHOD_ORDER.forEach((m) => {
|
||||
if (byMethod[m]?.length) ordered.push({ method: m, nodes: byMethod[m] });
|
||||
});
|
||||
Object.keys(byMethod).forEach((m) => {
|
||||
if (!IO_METHOD_ORDER.includes(m)) ordered.push({ method: m, nodes: byMethod[m] });
|
||||
});
|
||||
return ordered;
|
||||
}, [groupedByCategory]);
|
||||
|
||||
const handleDropNodeType = useCallback(
|
||||
(nodeTypeId: string, x: number, y: number) => {
|
||||
const nt = nodeTypes.find((n) => n.id === nodeTypeId);
|
||||
if (!nt) return;
|
||||
const id = `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
const label =
|
||||
typeof nt.label === 'string' ? nt.label : (nt.label as Record<string, string>)?.[language] ?? nt.id;
|
||||
setCanvasNodes((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
type: nodeTypeId,
|
||||
x,
|
||||
y,
|
||||
label,
|
||||
title: label,
|
||||
color: nt.meta?.color,
|
||||
inputs: nt.inputs ?? 1,
|
||||
outputs: nt.outputs ?? 1,
|
||||
parameters: {},
|
||||
},
|
||||
]);
|
||||
},
|
||||
[nodeTypes, language]
|
||||
);
|
||||
|
||||
const orderedCategories = useMemo(() => {
|
||||
const order = ['trigger', 'input', 'flow', 'data', 'io'];
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
order.forEach((id) => {
|
||||
if (groupedByCategory[id]) {
|
||||
result.push(id);
|
||||
seen.add(id);
|
||||
}
|
||||
});
|
||||
Object.keys(groupedByCategory).forEach((id) => {
|
||||
if (!seen.has(id)) result.push(id);
|
||||
});
|
||||
return result;
|
||||
}, [groupedByCategory]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sidebar}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<h3 className={styles.sidebarTitle}>Nodes</h3>
|
||||
</div>
|
||||
<div className={styles.loading}>
|
||||
<FaSpinner className={styles.spinner} style={{ marginBottom: '0.5rem' }} />
|
||||
<p>Lade Node-Typen...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.canvas}>
|
||||
<div className={styles.canvasArea} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.sidebar}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<h3 className={styles.sidebarTitle}>Nodes</h3>
|
||||
</div>
|
||||
<div className={styles.error}>
|
||||
<p>{error}</p>
|
||||
<button className={styles.retryButton} onClick={loadNodeTypes}>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.canvas}>
|
||||
<div className={styles.canvasArea} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
{/* Sidebar */}
|
||||
<div className={styles.sidebar}>
|
||||
<div className={styles.sidebarHeader}>
|
||||
<h3 className={styles.sidebarTitle}>Nodes</h3>
|
||||
<input
|
||||
type="text"
|
||||
className={styles.sidebarSearch}
|
||||
placeholder="Nodes durchsuchen..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.nodeList}>
|
||||
{orderedCategories.map((catId) => {
|
||||
const isExpanded = expandedCategories.has(catId);
|
||||
const catLabel = categories.find((c) => c.id === catId);
|
||||
const label = getLabel(catLabel?.label, language) || catId;
|
||||
|
||||
// I/O category: render sub-groups directly (KI, Kontext, Outlook, etc.) – keine E/A-Überschrift
|
||||
if (catId === 'io' && ioSubGroups.length > 0) {
|
||||
return (
|
||||
<React.Fragment key={catId}>
|
||||
{ioSubGroups.map(({ method, nodes }) => {
|
||||
const methodLabel = getIoMethodLabel(method, language);
|
||||
const isMethodExpanded = expandedIoMethods.has(method);
|
||||
return (
|
||||
<div key={`io-${method}`} className={styles.categoryGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.categoryHeader}
|
||||
onClick={() => toggleIoMethod(method)}
|
||||
>
|
||||
{isMethodExpanded ? (
|
||||
<FaChevronDown className={styles.categoryIcon} />
|
||||
) : (
|
||||
<FaChevronRight className={styles.categoryIcon} />
|
||||
)}
|
||||
<span className={styles.categoryLabel}>{methodLabel}</span>
|
||||
<span className={styles.categoryCount}>{nodes.length}</span>
|
||||
</button>
|
||||
{isMethodExpanded &&
|
||||
nodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className={styles.nodeItem}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(
|
||||
'application/json',
|
||||
JSON.stringify({ type: node.id })
|
||||
);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.nodeItemIcon}
|
||||
style={{
|
||||
backgroundColor: node.meta?.color
|
||||
? `${node.meta.color}20`
|
||||
: 'var(--bg-tertiary, #e9ecef)',
|
||||
color: node.meta?.color ?? 'var(--text-secondary, #666)',
|
||||
}}
|
||||
>
|
||||
{getCategoryIcon(node.category)}
|
||||
</div>
|
||||
<div className={styles.nodeItemInfo}>
|
||||
<span className={styles.nodeItemLabel}>
|
||||
{getLabel(node.label, language)}
|
||||
</span>
|
||||
<span className={styles.nodeItemDesc}>
|
||||
{getLabel(node.description, language)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
const items = groupedByCategory[catId] || [];
|
||||
return (
|
||||
<div key={catId} className={styles.categoryGroup}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.categoryHeader}
|
||||
onClick={() => toggleCategory(catId)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<FaChevronDown className={styles.categoryIcon} />
|
||||
) : (
|
||||
<FaChevronRight className={styles.categoryIcon} />
|
||||
)}
|
||||
<span className={styles.categoryLabel}>{label}</span>
|
||||
<span className={styles.categoryCount}>{items.length}</span>
|
||||
</button>
|
||||
{isExpanded &&
|
||||
items.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className={styles.nodeItem}
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData(
|
||||
'application/json',
|
||||
JSON.stringify({ type: node.id })
|
||||
);
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.nodeItemIcon}
|
||||
style={{
|
||||
backgroundColor: node.meta?.color
|
||||
? `${node.meta.color}20`
|
||||
: 'var(--bg-tertiary, #e9ecef)',
|
||||
color: node.meta?.color ?? 'var(--text-secondary, #666)',
|
||||
}}
|
||||
>
|
||||
{getCategoryIcon(node.category)}
|
||||
</div>
|
||||
<div className={styles.nodeItemInfo}>
|
||||
<span className={styles.nodeItemLabel}>
|
||||
{getLabel(node.label, language)}
|
||||
</span>
|
||||
<span className={styles.nodeItemDesc}>
|
||||
{getLabel(node.description, language)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div className={styles.canvas}>
|
||||
<div className={styles.canvasHeader}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
|
||||
<h4 className={styles.canvasTitle} style={{ margin: 0 }}>Workflow-Editor</h4>
|
||||
<button type="button" className={styles.retryButton} onClick={handleNew}>
|
||||
Neu
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={handleSave}
|
||||
disabled={saving || canvasNodes.length === 0}
|
||||
>
|
||||
{saving ? <FaSpinner className={styles.spinner} /> : 'Speichern'}
|
||||
</button>
|
||||
<select
|
||||
value={currentWorkflowId ?? ''}
|
||||
onChange={(e) => {
|
||||
const id = e.target.value || null;
|
||||
setCurrentWorkflowId(id);
|
||||
if (id) handleLoad(id);
|
||||
else handleNew();
|
||||
}}
|
||||
style={{ padding: '0.4rem', minWidth: 180 }}
|
||||
>
|
||||
<option value="">— Workflow laden —</option>
|
||||
{workflows.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.retryButton}
|
||||
onClick={handleExecute}
|
||||
disabled={executing || canvasNodes.length === 0}
|
||||
>
|
||||
{executing ? (
|
||||
<>
|
||||
<FaSpinner className={styles.spinner} style={{ marginRight: '0.5rem', display: 'inline-block' }} />
|
||||
Ausführen…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaPlay style={{ marginRight: '0.5rem' }} />
|
||||
Ausführen
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{executeResult && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
padding: '0.5rem',
|
||||
borderRadius: 6,
|
||||
fontSize: '0.875rem',
|
||||
background: executeResult.success
|
||||
? 'rgba(40,167,69,0.15)'
|
||||
: (executeResult as { paused?: boolean }).paused
|
||||
? 'rgba(0,123,255,0.15)'
|
||||
: 'rgba(220,53,69,0.15)',
|
||||
color: executeResult.success
|
||||
? 'var(--success-color,#28a745)'
|
||||
: (executeResult as { paused?: boolean }).paused
|
||||
? 'var(--primary-color,#007bff)'
|
||||
: 'var(--danger-color,#dc3545)',
|
||||
}}
|
||||
>
|
||||
{executeResult.success ? (
|
||||
<>✓ Ausführung abgeschlossen.</>
|
||||
) : (executeResult as { paused?: boolean }).paused ? (
|
||||
<>⏸ Workflow pausiert. Öffne <strong>Workflows & Tasks</strong> in der Sidebar, um den Task zu bearbeiten.</>
|
||||
) : (
|
||||
<>✗ {executeResult.error ?? 'Unbekannter Fehler'}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<FlowCanvas
|
||||
nodes={canvasNodes}
|
||||
connections={canvasConnections}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={setCanvasNodes}
|
||||
onConnectionsChange={setCanvasConnections}
|
||||
onDropNodeType={handleDropNodeType}
|
||||
getLabel={(node) => node.title ?? node.label ?? node.type}
|
||||
getCategoryIcon={getCategoryIcon}
|
||||
onSelectionChange={setSelectedNode}
|
||||
/>
|
||||
</div>
|
||||
{selectedNode?.type?.startsWith('input.') && (
|
||||
<NodeConfigPanel
|
||||
node={selectedNode}
|
||||
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
|
||||
language={language}
|
||||
onParametersChange={handleNodeParametersChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Automation2FlowEditor;
|
||||
508
src/components/Automation2FlowEditor/FlowCanvas.tsx
Normal file
508
src/components/Automation2FlowEditor/FlowCanvas.tsx
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
/**
|
||||
* FlowCanvas - Workflow graph canvas with nodes and connection lines.
|
||||
* Nodes have 4 connection handles (one per side), drag nodes to add, connect with arrows.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { NodeType } from '../../api/automation2Api';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
export interface CanvasNode {
|
||||
id: string;
|
||||
type: string;
|
||||
x: number;
|
||||
y: number;
|
||||
label?: string;
|
||||
title?: string;
|
||||
comment?: string;
|
||||
color?: string;
|
||||
inputs: number;
|
||||
outputs: number;
|
||||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface CanvasConnection {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
sourceHandle: number;
|
||||
targetId: string;
|
||||
targetHandle: number;
|
||||
}
|
||||
|
||||
const NODE_WIDTH = 200;
|
||||
const NODE_HEIGHT = 72;
|
||||
const HANDLE_SIZE = 12;
|
||||
const HANDLE_OFFSET = HANDLE_SIZE / 2;
|
||||
|
||||
interface FlowCanvasProps {
|
||||
nodes: CanvasNode[];
|
||||
connections: CanvasConnection[];
|
||||
nodeTypes: NodeType[];
|
||||
onNodesChange: (nodes: CanvasNode[]) => void;
|
||||
onConnectionsChange: (connections: CanvasConnection[]) => void;
|
||||
onDropNodeType: (nodeTypeId: string, x: number, y: number) => void;
|
||||
getLabel: (node: CanvasNode) => string;
|
||||
getCategoryIcon: (category: string) => React.ReactNode;
|
||||
onSelectionChange?: (node: CanvasNode | null) => void;
|
||||
}
|
||||
|
||||
export const FlowCanvas: React.FC<FlowCanvasProps> = ({
|
||||
nodes,
|
||||
connections,
|
||||
nodeTypes,
|
||||
onNodesChange,
|
||||
onConnectionsChange,
|
||||
onDropNodeType,
|
||||
getLabel,
|
||||
getCategoryIcon,
|
||||
onSelectionChange,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
|
||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
|
||||
const [editingField, setEditingField] = useState<'title' | 'comment' | null>(null);
|
||||
const [connectingFrom, setConnectingFrom] = useState<{
|
||||
nodeId: string;
|
||||
handleIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
} | null>(null);
|
||||
const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
|
||||
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null);
|
||||
const [dragOffset, setDragOffset] = useState({ dx: 0, dy: 0 });
|
||||
|
||||
const nodeTypeMap = useMemo(() => {
|
||||
const m: Record<string, NodeType> = {};
|
||||
nodeTypes.forEach((nt) => {
|
||||
m[nt.id] = nt;
|
||||
});
|
||||
return m;
|
||||
}, [nodeTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onSelectionChange) {
|
||||
const node = selectedNodeId ? nodes.find((n) => n.id === selectedNodeId) ?? null : null;
|
||||
onSelectionChange(node);
|
||||
}
|
||||
}, [selectedNodeId, nodes, onSelectionChange]);
|
||||
|
||||
const getHandlePosition = useCallback(
|
||||
(node: CanvasNode, handleIndex: number): { x: number; y: number; side: string } => {
|
||||
const isOutput = handleIndex >= node.inputs;
|
||||
const ioIndex = isOutput ? handleIndex - node.inputs : handleIndex;
|
||||
const ioCount = isOutput ? node.outputs : node.inputs;
|
||||
|
||||
const w = NODE_WIDTH;
|
||||
const h = NODE_HEIGHT;
|
||||
const centerX = node.x + w / 2;
|
||||
const centerY = node.y + h / 2;
|
||||
|
||||
if (isOutput) {
|
||||
if (ioCount === 1) return { x: node.x + w, y: centerY, side: 'right' };
|
||||
if (ioCount === 2) {
|
||||
return ioIndex === 0
|
||||
? { x: node.x + w, y: node.y + h / 3, side: 'right' }
|
||||
: { x: node.x + w, y: node.y + (2 * h) / 3, side: 'right' };
|
||||
}
|
||||
const step = h / (ioCount + 1);
|
||||
return { x: node.x + w, y: node.y + step * (ioIndex + 1), side: 'right' };
|
||||
} else {
|
||||
if (ioCount === 1) return { x: node.x, y: centerY, side: 'left' };
|
||||
if (ioCount === 2) {
|
||||
return ioIndex === 0
|
||||
? { x: node.x, y: node.y + h / 3, side: 'left' }
|
||||
: { x: node.x, y: node.y + (2 * h) / 3, side: 'left' };
|
||||
}
|
||||
const step = h / (ioCount + 1);
|
||||
return { x: node.x, y: node.y + step * (ioIndex + 1), side: 'left' };
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const getUsedTargetHandles = useMemo(() => {
|
||||
const used = new Set<string>();
|
||||
connections.forEach((c) => used.add(`${c.targetId}-${c.targetHandle}`));
|
||||
return used;
|
||||
}, [connections]);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const raw = e.dataTransfer.getData('application/json');
|
||||
if (!raw || !containerRef.current) return;
|
||||
try {
|
||||
const { type } = JSON.parse(raw);
|
||||
const el = containerRef.current;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left + el.scrollLeft - NODE_WIDTH / 2;
|
||||
const y = e.clientY - rect.top + el.scrollTop - NODE_HEIGHT / 2;
|
||||
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
|
||||
} catch (_) {}
|
||||
},
|
||||
[onDropNodeType]
|
||||
);
|
||||
|
||||
const handleHandleMouseDown = useCallback(
|
||||
(e: React.MouseEvent, nodeId: string, handleIndex: number, isOutput: boolean) => {
|
||||
e.stopPropagation();
|
||||
if (!isOutput) return;
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return;
|
||||
const pos = getHandlePosition(node, handleIndex);
|
||||
setConnectingFrom({ nodeId, handleIndex, x: pos.x, y: pos.y });
|
||||
setDragPos({ x: e.clientX, y: e.clientY });
|
||||
},
|
||||
[nodes, getHandlePosition]
|
||||
);
|
||||
|
||||
const handleHandleMouseUp = useCallback(
|
||||
(e: React.MouseEvent, targetNodeId: string, targetHandleIndex: number) => {
|
||||
e.stopPropagation();
|
||||
if (!connectingFrom || connectingFrom.nodeId === targetNodeId) {
|
||||
setConnectingFrom(null);
|
||||
setDragPos(null);
|
||||
return;
|
||||
}
|
||||
const key = `${targetNodeId}-${targetHandleIndex}`;
|
||||
if (getUsedTargetHandles.has(key)) {
|
||||
setConnectingFrom(null);
|
||||
setDragPos(null);
|
||||
return;
|
||||
}
|
||||
const targetNode = nodes.find((n) => n.id === targetNodeId);
|
||||
if (!targetNode) return;
|
||||
if (targetHandleIndex >= targetNode.inputs) return;
|
||||
const newConn: CanvasConnection = {
|
||||
id: `c_${Date.now()}`,
|
||||
sourceId: connectingFrom.nodeId,
|
||||
sourceHandle: connectingFrom.handleIndex,
|
||||
targetId: targetNodeId,
|
||||
targetHandle: targetHandleIndex,
|
||||
};
|
||||
onConnectionsChange([...connections, newConn]);
|
||||
setConnectingFrom(null);
|
||||
setDragPos(null);
|
||||
},
|
||||
[connectingFrom, connections, nodes, getUsedTargetHandles, onConnectionsChange]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!connectingFrom || !dragPos) return;
|
||||
const onMove = (e: MouseEvent) => setDragPos({ x: e.clientX, y: e.clientY });
|
||||
const onUp = () => {
|
||||
setConnectingFrom(null);
|
||||
setDragPos(null);
|
||||
};
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [connectingFrom, dragPos]);
|
||||
|
||||
const handleNodeMouseDown = useCallback((e: React.MouseEvent, nodeId: string) => {
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node) return;
|
||||
setDraggingNodeId(nodeId);
|
||||
setDragOffset({ dx: e.clientX - node.x, dy: e.clientY - node.y });
|
||||
}, [nodes]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!draggingNodeId) return;
|
||||
const onMove = (e: MouseEvent) => {
|
||||
onNodesChange(
|
||||
nodes.map((n) =>
|
||||
n.id === draggingNodeId
|
||||
? { ...n, x: e.clientX - dragOffset.dx, y: e.clientY - dragOffset.dy }
|
||||
: n
|
||||
)
|
||||
);
|
||||
};
|
||||
const onUp = () => setDraggingNodeId(null);
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', onMove);
|
||||
window.removeEventListener('mouseup', onUp);
|
||||
};
|
||||
}, [draggingNodeId, dragOffset, nodes, onNodesChange]);
|
||||
|
||||
const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0, scrollLeft: 0, scrollTop: 0 });
|
||||
React.useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setContainerBounds({ left: r.left, top: r.top, scrollLeft: el.scrollLeft, scrollTop: el.scrollTop });
|
||||
};
|
||||
update();
|
||||
el.addEventListener('scroll', update);
|
||||
window.addEventListener('resize', update);
|
||||
return () => {
|
||||
el.removeEventListener('scroll', update);
|
||||
window.removeEventListener('resize', update);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const svgBounds = useMemo(() => {
|
||||
if (nodes.length === 0) return { width: 2000, height: 1500 };
|
||||
let maxX = 0, maxY = 0;
|
||||
nodes.forEach((n) => {
|
||||
maxX = Math.max(maxX, n.x + NODE_WIDTH + 100);
|
||||
maxY = Math.max(maxY, n.y + NODE_HEIGHT + 100);
|
||||
});
|
||||
return { width: Math.max(maxX, 2000), height: Math.max(maxY, 1500) };
|
||||
}, [nodes]);
|
||||
|
||||
const screenToSvg = useCallback(
|
||||
(clientX: number, clientY: number) => ({
|
||||
x: clientX - containerBounds.left + containerBounds.scrollLeft,
|
||||
y: clientY - containerBounds.top + containerBounds.scrollTop,
|
||||
}),
|
||||
[containerBounds]
|
||||
);
|
||||
|
||||
const handleDeleteNode = useCallback(() => {
|
||||
if (!selectedNodeId) return;
|
||||
onNodesChange(nodes.filter((n) => n.id !== selectedNodeId));
|
||||
onConnectionsChange(
|
||||
connections.filter((c) => c.sourceId !== selectedNodeId && c.targetId !== selectedNodeId)
|
||||
);
|
||||
setSelectedNodeId(null);
|
||||
setEditingNodeId(null);
|
||||
setEditingField(null);
|
||||
}, [selectedNodeId, nodes, connections, onNodesChange, onConnectionsChange]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.key === 'Delete' || e.key === 'Backspace') && selectedNodeId) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||
e.preventDefault();
|
||||
handleDeleteNode();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [handleDeleteNode, selectedNodeId]);
|
||||
|
||||
const handleNodeUpdate = useCallback(
|
||||
(nodeId: string, updates: Partial<Pick<CanvasNode, 'title' | 'comment'>>) => {
|
||||
onNodesChange(
|
||||
nodes.map((n) => (n.id === nodeId ? { ...n, ...updates } : n))
|
||||
);
|
||||
},
|
||||
[nodes, onNodesChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.canvasDropZone}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDrop={handleDrop}
|
||||
tabIndex={0}
|
||||
onClick={() => setSelectedNodeId(null)}
|
||||
>
|
||||
<svg
|
||||
className={styles.connectionsLayer}
|
||||
width={svgBounds.width}
|
||||
height={svgBounds.height}
|
||||
style={{ position: 'absolute', left: 0, top: 0, pointerEvents: 'none' }}
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="9"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="var(--text-secondary, #666)" />
|
||||
</marker>
|
||||
</defs>
|
||||
{connections.map((c) => {
|
||||
const srcNode = nodes.find((n) => n.id === c.sourceId);
|
||||
const tgtNode = nodes.find((n) => n.id === c.targetId);
|
||||
if (!srcNode || !tgtNode) return null;
|
||||
const src = getHandlePosition(srcNode, c.sourceHandle);
|
||||
const tgt = getHandlePosition(tgtNode, c.targetHandle);
|
||||
const dx = tgt.x - src.x;
|
||||
const path = `M ${src.x} ${src.y} C ${src.x + Math.abs(dx) / 2} ${src.y}, ${tgt.x - Math.abs(dx) / 2} ${tgt.y}, ${tgt.x} ${tgt.y}`;
|
||||
return (
|
||||
<path
|
||||
key={c.id}
|
||||
d={path}
|
||||
fill="none"
|
||||
stroke="var(--text-secondary, #666)"
|
||||
strokeWidth="2"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{connectingFrom && dragPos && (() => {
|
||||
const end = screenToSvg(dragPos.x, dragPos.y);
|
||||
return (
|
||||
<path
|
||||
d={`M ${connectingFrom.x} ${connectingFrom.y} L ${end.x} ${end.y}`}
|
||||
fill="none"
|
||||
stroke="var(--primary-color, #007bff)"
|
||||
strokeWidth="2"
|
||||
strokeDasharray="4 4"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</svg>
|
||||
{nodes.map((node) => {
|
||||
const nt = nodeTypeMap[node.type];
|
||||
const category = nt?.category ?? 'io';
|
||||
const color = node.color ?? nt?.meta?.color ?? '#00BCD4';
|
||||
const totalHandles = node.inputs + node.outputs;
|
||||
const handles: Array<{ index: number; isOutput: boolean }> = [];
|
||||
for (let i = 0; i < node.inputs; i++) handles.push({ index: i, isOutput: false });
|
||||
for (let i = 0; i < node.outputs; i++) handles.push({ index: node.inputs + i, isOutput: true });
|
||||
|
||||
const isSelected = selectedNodeId === node.id;
|
||||
const isEditingTitle = editingNodeId === node.id && editingField === 'title';
|
||||
const isEditingComment = editingNodeId === node.id && editingField === 'comment';
|
||||
const displayTitle = node.title ?? node.label ?? getLabel(node);
|
||||
const displayComment = node.comment ?? '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className={`${styles.canvasNode} ${isSelected ? styles.canvasNodeSelected : ''}`}
|
||||
style={{
|
||||
left: node.x,
|
||||
top: node.y,
|
||||
width: NODE_WIDTH,
|
||||
height: NODE_HEIGHT,
|
||||
borderColor: color,
|
||||
backgroundColor: `${color}15`,
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedNodeId(node.id);
|
||||
handleNodeMouseDown(e, node.id);
|
||||
}}
|
||||
>
|
||||
{handles.map(({ index, isOutput }) => {
|
||||
const pos = getHandlePosition(node, index);
|
||||
const used = !isOutput && getUsedTargetHandles.has(`${node.id}-${index}`);
|
||||
const canConnect = isOutput || (!used && connectingFrom);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`${styles.handle} ${isOutput ? styles.handleOutput : styles.handleInput} ${canConnect ? styles.handleConnectable : ''}`}
|
||||
style={{
|
||||
left: pos.side === 'left' ? -HANDLE_OFFSET : undefined,
|
||||
right: pos.side === 'right' ? -HANDLE_OFFSET : undefined,
|
||||
top: pos.y - node.y - HANDLE_OFFSET,
|
||||
width: HANDLE_SIZE,
|
||||
height: HANDLE_SIZE,
|
||||
}}
|
||||
onMouseDown={(e) => handleHandleMouseDown(e, node.id, index, isOutput)}
|
||||
onMouseUp={(e) => !isOutput && handleHandleMouseUp(e, node.id, index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className={styles.canvasNodeContent}>
|
||||
<div
|
||||
className={styles.canvasNodeIcon}
|
||||
style={{ backgroundColor: `${color}40`, color }}
|
||||
>
|
||||
{getCategoryIcon(category)}
|
||||
</div>
|
||||
<div className={styles.canvasNodeText}>
|
||||
{isEditingTitle ? (
|
||||
<input
|
||||
type="text"
|
||||
className={styles.canvasNodeInput}
|
||||
value={node.title ?? displayTitle}
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleNodeUpdate(node.id, { title: (e.target as HTMLInputElement).value });
|
||||
setEditingNodeId(null);
|
||||
setEditingField(null);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditingNodeId(null);
|
||||
setEditingField(null);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
handleNodeUpdate(node.id, { title: e.target.value });
|
||||
setEditingNodeId(null);
|
||||
setEditingField(null);
|
||||
}}
|
||||
onChange={(e) => handleNodeUpdate(node.id, { title: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={styles.canvasNodeTitle}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingNodeId(node.id);
|
||||
setEditingField('title');
|
||||
}}
|
||||
>
|
||||
{displayTitle}
|
||||
</span>
|
||||
)}
|
||||
{isEditingComment ? (
|
||||
<input
|
||||
type="text"
|
||||
className={styles.canvasNodeInput}
|
||||
placeholder="Kommentar..."
|
||||
value={node.comment ?? ''}
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleNodeUpdate(node.id, { comment: (e.target as HTMLInputElement).value });
|
||||
setEditingNodeId(null);
|
||||
setEditingField(null);
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
setEditingNodeId(null);
|
||||
setEditingField(null);
|
||||
}
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
handleNodeUpdate(node.id, { comment: e.target.value });
|
||||
setEditingNodeId(null);
|
||||
setEditingField(null);
|
||||
}}
|
||||
onChange={(e) => handleNodeUpdate(node.id, { comment: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={styles.canvasNodeComment}
|
||||
onDoubleClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingNodeId(node.id);
|
||||
setEditingField('comment');
|
||||
}}
|
||||
>
|
||||
{displayComment || 'Doppelklick für Kommentar'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{nodes.length === 0 && (
|
||||
<div className={styles.canvasPlaceholder}>
|
||||
<p>Nodes aus der Liste links hierher ziehen.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
318
src/components/Automation2FlowEditor/NodeConfigPanel.tsx
Normal file
318
src/components/Automation2FlowEditor/NodeConfigPanel.tsx
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/**
|
||||
* NodeConfigPanel - Configures parameters for input/human nodes.
|
||||
* Form fields: draggable, required toggle, layout ohne clipping.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { FaGripVertical } from 'react-icons/fa';
|
||||
import type { CanvasNode } from './FlowCanvas';
|
||||
import type { NodeType } from '../../api/automation2Api';
|
||||
import styles from './Automation2FlowEditor.module.css';
|
||||
|
||||
type FormField = { name?: string; type?: string; label?: string; required?: boolean };
|
||||
|
||||
interface NodeConfigPanelProps {
|
||||
node: CanvasNode | null;
|
||||
nodeType: NodeType | undefined;
|
||||
language: string;
|
||||
onParametersChange: (nodeId: string, parameters: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
||||
node,
|
||||
nodeType,
|
||||
language,
|
||||
onParametersChange,
|
||||
}) => {
|
||||
const [params, setParams] = useState<Record<string, unknown>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setParams(node?.parameters ?? {});
|
||||
}, [node?.id, node?.parameters]);
|
||||
|
||||
const updateParam = (key: string, value: unknown) => {
|
||||
const next = { ...params, [key]: value };
|
||||
setParams(next);
|
||||
if (node) onParametersChange(node.id, next);
|
||||
};
|
||||
|
||||
if (!node || !node.type.startsWith('input.')) return null;
|
||||
const nt = nodeType;
|
||||
const getLabel = (text: string | Record<string, string> | undefined) => {
|
||||
if (!text) return '';
|
||||
if (typeof text === 'string') return text;
|
||||
return (text as Record<string, string>)[language] ?? (text as Record<string, string>).en ?? '';
|
||||
};
|
||||
|
||||
const renderConfig = () => {
|
||||
switch (node.type) {
|
||||
case 'input.form': {
|
||||
const fields = (params.fields as FormField[]) ?? [];
|
||||
const moveField = (fromIndex: number, toIndex: number) => {
|
||||
if (fromIndex < 0 || toIndex < 0 || fromIndex >= fields.length || toIndex >= fields.length) return;
|
||||
const next = [...fields];
|
||||
const [removed] = next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, removed);
|
||||
updateParam('fields', next);
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<label>Felder</label>
|
||||
<div className={styles.formFieldsList}>
|
||||
{fields.map((f, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={styles.formFieldRow}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
const from = parseInt(e.dataTransfer.getData('text/plain'), 10);
|
||||
if (!Number.isNaN(from) && from !== i) moveField(from, i);
|
||||
}}
|
||||
>
|
||||
<div className={styles.formFieldRowHeader}>
|
||||
<span
|
||||
className={styles.formFieldDragHandle}
|
||||
title="Zum Verschieben ziehen"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.dataTransfer.setData('text/plain', String(i));
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
}}
|
||||
>
|
||||
<FaGripVertical />
|
||||
</span>
|
||||
<div className={styles.formFieldInputs}>
|
||||
<input
|
||||
placeholder="name"
|
||||
value={f.name ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
next[i] = { ...next[i], name: e.target.value };
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="label"
|
||||
value={f.label ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
next[i] = { ...next[i], label: e.target.value };
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.formFieldRowFooter}>
|
||||
<select
|
||||
value={f.type ?? 'string'}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
next[i] = { ...next[i], type: e.target.value };
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
style={{ width: 'auto', minWidth: 90 }}
|
||||
>
|
||||
<option value="string">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="boolean">Checkbox</option>
|
||||
</select>
|
||||
<label className={styles.formFieldRequiredLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={f.required ?? false}
|
||||
onChange={(e) => {
|
||||
const next = [...fields];
|
||||
next[i] = { ...next[i], required: e.target.checked };
|
||||
updateParam('fields', next);
|
||||
}}
|
||||
/>
|
||||
Pflichtfeld
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])}
|
||||
>
|
||||
+ Feld
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'input.approval':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label>Titel</label>
|
||||
<input
|
||||
value={(params.title as string) ?? ''}
|
||||
onChange={(e) => updateParam('title', e.target.value)}
|
||||
placeholder="Genehmigungstitel"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Beschreibung</label>
|
||||
<textarea
|
||||
value={(params.description as string) ?? ''}
|
||||
onChange={(e) => updateParam('description', e.target.value)}
|
||||
placeholder="Was genehmigt werden soll"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'input.upload':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label>Accept (MIME)</label>
|
||||
<input
|
||||
value={(params.accept as string) ?? ''}
|
||||
onChange={(e) => updateParam('accept', e.target.value)}
|
||||
placeholder=".pdf,image/*"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Max Größe (MB)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={(params.maxSize as number) ?? 10}
|
||||
onChange={(e) => updateParam('maxSize', parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(params.multiple as boolean) ?? false}
|
||||
onChange={(e) => updateParam('multiple', e.target.checked)}
|
||||
/>
|
||||
Mehrere Dateien
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'input.comment':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label>Platzhalter</label>
|
||||
<input
|
||||
value={(params.placeholder as string) ?? ''}
|
||||
onChange={(e) => updateParam('placeholder', e.target.value)}
|
||||
placeholder="Kommentar eingeben..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(params.required as boolean) ?? true}
|
||||
onChange={(e) => updateParam('required', e.target.checked)}
|
||||
/>
|
||||
Pflichtfeld
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'input.review':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label>Content-Referenz</label>
|
||||
<input
|
||||
value={(params.contentRef as string) ?? ''}
|
||||
onChange={(e) => updateParam('contentRef', e.target.value)}
|
||||
placeholder="{{nodeId.field}}"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
case 'input.selection': {
|
||||
const options = (params.options as Array<{ value?: string; label?: string }>) ?? [];
|
||||
return (
|
||||
<div>
|
||||
<label>Optionen</label>
|
||||
{options.map((o, i) => (
|
||||
<div key={i} style={{ display: 'flex', gap: 4, marginBottom: 4 }}>
|
||||
<input
|
||||
placeholder="value"
|
||||
value={o.value ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...options];
|
||||
next[i] = { ...next[i], value: e.target.value };
|
||||
updateParam('options', next);
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
placeholder="label"
|
||||
value={o.label ?? ''}
|
||||
onChange={(e) => {
|
||||
const next = [...options];
|
||||
next[i] = { ...next[i], label: e.target.value };
|
||||
updateParam('options', next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<button type="button" onClick={() => updateParam('options', [...options, { value: '', label: '' }])}>
|
||||
+ Option
|
||||
</button>
|
||||
<div>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(params.multiple as boolean) ?? false}
|
||||
onChange={(e) => updateParam('multiple', e.target.checked)}
|
||||
/>
|
||||
Mehrfachauswahl
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'input.confirmation':
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label>Frage</label>
|
||||
<input
|
||||
value={(params.question as string) ?? ''}
|
||||
onChange={(e) => updateParam('question', e.target.value)}
|
||||
placeholder="Möchten Sie bestätigen?"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Bestätigen-Button</label>
|
||||
<input
|
||||
value={(params.confirmLabel as string) ?? 'Confirm'}
|
||||
onChange={(e) => updateParam('confirmLabel', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label>Ablehnen-Button</label>
|
||||
<input
|
||||
value={(params.rejectLabel as string) ?? 'Reject'}
|
||||
onChange={(e) => updateParam('rejectLabel', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
default:
|
||||
return <p>Keine Konfiguration für {node.type}</p>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.nodeConfigPanel}>
|
||||
<h4>{getLabel(nt?.label) || node.type}</h4>
|
||||
{renderConfig()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
src/components/Automation2FlowEditor/index.ts
Normal file
1
src/components/Automation2FlowEditor/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Automation2FlowEditor } from './Automation2FlowEditor';
|
||||
|
|
@ -111,6 +111,9 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'feature.realestate': <FaBuilding />,
|
||||
'feature.chatworkflow': <FaPlay />,
|
||||
'feature.automation': <FaCogs />,
|
||||
'feature.automation2': <FaProjectDiagram />,
|
||||
'page.feature.automation2.editor': <FaProjectDiagram />,
|
||||
'page.feature.automation2.workflows-tasks': <FaClipboardList />,
|
||||
'page.feature.chatbot.conversations': <FaComments />,
|
||||
'feature.chatbot': <FaComments />,
|
||||
'feature.teamsbot': <FaHeadset />,
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
|
|
@ -30,6 +31,10 @@ import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/r
|
|||
// Automation Views
|
||||
import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView } from './views/automation';
|
||||
|
||||
// Automation2 Views
|
||||
import { Automation2Page } from './views/automation2/Automation2Page';
|
||||
import { Automation2WorkflowsTasksPage } from './views/automation2/Automation2WorkflowsTasksPage';
|
||||
|
||||
// Workspace Views
|
||||
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
||||
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
|
||||
|
|
@ -128,6 +133,10 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
|||
templates: AutomationTemplatesView,
|
||||
logs: AutomationLogsView,
|
||||
},
|
||||
automation2: {
|
||||
editor: Automation2Page,
|
||||
'workflows-tasks': Automation2WorkflowsTasksPage,
|
||||
},
|
||||
workspace: {
|
||||
dashboard: WorkspacePage,
|
||||
editor: WorkspaceEditorPage,
|
||||
|
|
@ -161,7 +170,13 @@ interface FeatureViewPageProps {
|
|||
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||
const { instance, featureCode, isValid } = useCurrentInstance();
|
||||
const { currentLanguage } = useLanguage();
|
||||
|
||||
const { mandateId, instanceId } = useParams<{ mandateId?: string; instanceId?: string }>();
|
||||
|
||||
// automation2: Dashboard entfernt → Index/Base-URL auf Editor umleiten
|
||||
if (featureCode === 'automation2' && view === 'dashboard' && mandateId && instanceId) {
|
||||
return <Navigate to={`/mandates/${mandateId}/automation2/${instanceId}/editor`} replace />;
|
||||
}
|
||||
|
||||
// Berechtigungs-Check
|
||||
const viewCode = `${featureCode}-${view}`;
|
||||
const canView = useCanViewFeatureView(viewCode);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@
|
|||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { FaCogs, FaComments, FaHeadset } from 'react-icons/fa';
|
||||
import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { useStore } from '../hooks/useStore';
|
||||
import type { StoreFeature } from '../api/storeApi';
|
||||
|
|
@ -15,6 +15,7 @@ import styles from './Store.module.css';
|
|||
|
||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||
automation: <FaCogs />,
|
||||
automation2: <FaProjectDiagram />,
|
||||
teamsbot: <FaHeadset />,
|
||||
workspace: <FaComments />,
|
||||
commcoach: <FaComments />,
|
||||
|
|
@ -26,6 +27,11 @@ const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = {
|
|||
en: 'Create and manage automations to handle recurring tasks efficiently.',
|
||||
fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.',
|
||||
},
|
||||
automation2: {
|
||||
de: 'n8n-style Flow-Automatisierung mit grafischem Editor, RAG und Tools.',
|
||||
en: 'n8n-style flow automation with visual editor, RAG and tools.',
|
||||
fr: 'Automatisation de flux style n8n avec editeur visuel, RAG et outils.',
|
||||
},
|
||||
teamsbot: {
|
||||
de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
||||
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
|
||||
|
|
|
|||
31
src/pages/views/automation2/Automation2Page.tsx
Normal file
31
src/pages/views/automation2/Automation2Page.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* Automation2Page
|
||||
*
|
||||
* n8n-style flow builder with backend-driven node list.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { useLanguage } from '../../../providers/language/LanguageContext';
|
||||
import { Automation2FlowEditor } from '../../../components/Automation2FlowEditor';
|
||||
import styles from '../../FeatureView.module.css';
|
||||
|
||||
export const Automation2Page: React.FC = () => {
|
||||
const instanceId = useInstanceId();
|
||||
const { currentLanguage } = useLanguage();
|
||||
const language = (currentLanguage?.slice(0, 2) || 'de') as string;
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<div className={styles.placeholder}>
|
||||
<h2>Automation 2</h2>
|
||||
<p>Keine Feature-Instanz gefunden.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
|
||||
<Automation2FlowEditor instanceId={instanceId} language={language} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
210
src/pages/views/automation2/Automation2WorkflowsTasks.module.css
Normal file
210
src/pages/views/automation2/Automation2WorkflowsTasks.module.css
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
.container {
|
||||
padding: 1.5rem;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.container h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.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'],
|
||||
.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;
|
||||
}
|
||||
361
src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
Normal file
361
src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
/**
|
||||
* Automation2WorkflowsTasksPage
|
||||
* Workflows (collapsible) with runs and tasks. Complete tasks by type (form, approval, upload, etc.)
|
||||
* Form tasks open in Popup for fill-in and submit.
|
||||
*/
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { FaChevronDown, FaChevronRight, FaSpinner } from 'react-icons/fa';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { useApiRequest } from '../../../hooks/useApi';
|
||||
import {
|
||||
fetchWorkflows,
|
||||
fetchTasks,
|
||||
completeTask,
|
||||
type Automation2Workflow,
|
||||
type Automation2Task,
|
||||
} from '../../../api/automation2Api';
|
||||
import { Popup } from '../../../components/UiComponents/Popup';
|
||||
import styles from './Automation2WorkflowsTasks.module.css';
|
||||
|
||||
export const Automation2WorkflowsTasksPage: React.FC = () => {
|
||||
const instanceId = useInstanceId();
|
||||
const { request } = useApiRequest();
|
||||
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
|
||||
const [tasks, setTasks] = useState<Automation2Task[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedWorkflows, setExpandedWorkflows] = useState<Set<string>>(new Set());
|
||||
const [submitting, setSubmitting] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!instanceId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const [wfList, taskList] = await Promise.all([
|
||||
fetchWorkflows(request, instanceId),
|
||||
fetchTasks(request, instanceId, { status: 'pending' }),
|
||||
]);
|
||||
setWorkflows(wfList);
|
||||
setTasks(taskList);
|
||||
} catch (e) {
|
||||
console.error('[Automation2] load failed', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [instanceId, request]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
const toggleWorkflow = (id: string) => {
|
||||
setExpandedWorkflows((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
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('[Automation2] complete failed', e);
|
||||
} finally {
|
||||
setSubmitting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const tasksByWorkflow = tasks.reduce<Record<string, Automation2Task[]>>((acc, t) => {
|
||||
const w = t.workflowId;
|
||||
if (!acc[w]) acc[w] = [];
|
||||
acc[w].push(t);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const workflowLabel = (wf: Automation2Workflow) => wf.label || wf.id;
|
||||
|
||||
if (!instanceId) {
|
||||
return (
|
||||
<div className={styles.placeholder}>
|
||||
<h2>Workflows & Tasks</h2>
|
||||
<p>Keine Feature-Instanz gefunden.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loading}>
|
||||
<FaSpinner className={styles.spinner} />
|
||||
<p>Lade Workflows und Tasks…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<h2>Workflows & Tasks</h2>
|
||||
<div className={styles.workflowList}>
|
||||
{workflows.map((wf) => {
|
||||
const isExpanded = expandedWorkflows.has(wf.id);
|
||||
const wfTasks = tasksByWorkflow[wf.id] ?? [];
|
||||
return (
|
||||
<div key={wf.id} className={styles.workflowItem}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.workflowHeader}
|
||||
onClick={() => toggleWorkflow(wf.id)}
|
||||
>
|
||||
{isExpanded ? <FaChevronDown /> : <FaChevronRight />}
|
||||
<span>{workflowLabel(wf)}</span>
|
||||
{wfTasks.length > 0 && <span className={styles.badge}>{wfTasks.length}</span>}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className={styles.taskList}>
|
||||
{wfTasks.length === 0 ? (
|
||||
<p className={styles.empty}>Keine offenen Tasks</p>
|
||||
) : (
|
||||
wfTasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
onSubmit={(result) => handleComplete(task.id, result)}
|
||||
submitting={submitting === task.id}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{workflows.length === 0 && (
|
||||
<p className={styles.empty}>Keine Workflows. Erstelle einen im Editor und speichere ihn.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Automation2Task;
|
||||
onSubmit: (result: Record<string, unknown>) => void;
|
||||
submitting: boolean;
|
||||
}
|
||||
|
||||
const TaskCard: React.FC<TaskCardProps> = ({ task, onSubmit, submitting }) => {
|
||||
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||
const [formPopupOpen, setFormPopupOpen] = useState(false);
|
||||
const config = task.config ?? {};
|
||||
const nodeType = task.nodeType;
|
||||
|
||||
const renderInput = () => {
|
||||
switch (nodeType) {
|
||||
case 'input.form': {
|
||||
const fields = (config.fields as Array<{ name: string; type: string; label: string; required?: boolean }>) ?? [];
|
||||
const requiredFields = fields.filter((f) => f.required);
|
||||
const allRequiredFilled = requiredFields.every((f) => {
|
||||
const v = formData[f.name];
|
||||
if (f.type === 'boolean') return true;
|
||||
return v !== undefined && v !== null && String(v).trim() !== '';
|
||||
});
|
||||
const formContent = (
|
||||
<div className={styles.formFields}>
|
||||
{fields.map((f) => (
|
||||
<div key={f.name}>
|
||||
<label>{f.label || f.name}{f.required && ' *'}</label>
|
||||
{f.type === 'boolean' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(formData[f.name] as boolean) ?? false}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, [f.name]: e.target.checked }))}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={f.type === 'number' ? 'number' : f.type === 'date' ? 'date' : 'text'}
|
||||
value={(formData[f.name] as string) ?? ''}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, [f.name]: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormPopupOpen(true)}
|
||||
disabled={submitting}
|
||||
className={styles.openFormButton}
|
||||
>
|
||||
Formular bearbeiten
|
||||
</button>
|
||||
<Popup
|
||||
isOpen={formPopupOpen}
|
||||
title="Formular ausfüllen"
|
||||
onClose={() => setFormPopupOpen(false)}
|
||||
size="medium"
|
||||
footerContent={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onSubmit(formData);
|
||||
setFormPopupOpen(false);
|
||||
}}
|
||||
disabled={submitting || !allRequiredFilled}
|
||||
className={styles.popupSubmitButton}
|
||||
>
|
||||
{submitting ? 'Wird gesendet…' : 'Absenden'}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{formContent}
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
case 'input.approval':
|
||||
return (
|
||||
<div>
|
||||
<h4>{config.title}</h4>
|
||||
{config.description && <p>{config.description}</p>}
|
||||
<div className={styles.approvalButtons}>
|
||||
<button type="button" onClick={() => onSubmit({ approved: true })} disabled={submitting}>
|
||||
Genehmigen
|
||||
</button>
|
||||
<button type="button" onClick={() => onSubmit({ approved: false })} disabled={submitting}>
|
||||
Ablehnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'input.comment':
|
||||
return (
|
||||
<div>
|
||||
<textarea
|
||||
placeholder={(config.placeholder as string) ?? '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)}
|
||||
>
|
||||
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}
|
||||
>
|
||||
Absenden
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case 'input.confirmation':
|
||||
return (
|
||||
<div>
|
||||
<p>{(config.question as string) ?? 'Bestätigen?'}</p>
|
||||
<div className={styles.approvalButtons}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubmit({ confirmed: true })}
|
||||
disabled={submitting}
|
||||
>
|
||||
{config.confirmLabel ?? 'Bestätigen'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSubmit({ confirmed: false })}
|
||||
disabled={submitting}
|
||||
>
|
||||
{config.rejectLabel ?? 'Ablehnen'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'input.upload':
|
||||
return (
|
||||
<div>
|
||||
<p>Upload-Komponente – noch nicht implementiert</p>
|
||||
<button type="button" onClick={() => onSubmit({ uploaded: [] })} disabled={submitting}>
|
||||
Platzhalter absenden
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
case 'input.review':
|
||||
return (
|
||||
<div>
|
||||
<p>Review – Content anzeigen + Feedback</p>
|
||||
<textarea
|
||||
placeholder="Feedback..."
|
||||
value={(formData.feedback as string) ?? ''}
|
||||
onChange={(e) => setFormData({ feedback: e.target.value })}
|
||||
/>
|
||||
<button type="button" onClick={() => onSubmit(formData)} disabled={submitting}>
|
||||
Absenden
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div>
|
||||
<p>Unbekannter Task-Typ: {nodeType}</p>
|
||||
<button type="button" onClick={() => onSubmit({})} disabled={submitting}>
|
||||
Absenden
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const nodeTypeLabel: Record<string, string> = {
|
||||
'input.form': 'Formular',
|
||||
'input.approval': 'Genehmigung',
|
||||
'input.upload': 'Upload',
|
||||
'input.comment': 'Kommentar',
|
||||
'input.review': 'Prüfung',
|
||||
'input.selection': 'Auswahl',
|
||||
'input.confirmation': 'Bestätigung',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.taskCard}>
|
||||
<div className={styles.taskType}>{nodeTypeLabel[nodeType] ?? nodeType}</div>
|
||||
{renderInput()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -261,6 +261,15 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
{ code: 'logs', label: { de: 'Protokolle', en: 'Logs' }, path: 'logs' },
|
||||
]
|
||||
},
|
||||
automation2: {
|
||||
code: 'automation2',
|
||||
label: { de: 'Automation 2', en: 'Automation 2' },
|
||||
icon: 'sitemap',
|
||||
views: [
|
||||
{ code: 'editor', label: { de: 'Editor', en: 'Editor' }, path: 'editor' },
|
||||
{ code: 'workflows-tasks', label: { de: 'Workflows & Tasks', en: 'Workflows & Tasks' }, path: 'workflows-tasks' },
|
||||
]
|
||||
},
|
||||
neutralization: {
|
||||
code: 'neutralization',
|
||||
label: { de: 'Neutralisierung', en: 'Neutralization', fr: 'Neutralisation' },
|
||||
|
|
|
|||
Loading…
Reference in a new issue