Merge branch 'int' into feat/service-subscription
This commit is contained in:
commit
dda7a2cac9
35 changed files with 4583 additions and 3 deletions
|
|
@ -154,10 +154,14 @@ function App() {
|
||||||
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
<Route path="templates" element={<FeatureViewPage view="templates" />} />
|
||||||
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
<Route path="logs" element={<FeatureViewPage view="logs" />} />
|
||||||
|
|
||||||
{/* Workspace Editor */}
|
{/* Workspace + Automation2 Editor */}
|
||||||
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
||||||
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
|
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
|
||||||
|
|
||||||
|
{/* Automation2 Workflows & Tasks */}
|
||||||
|
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
|
||||||
|
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
|
||||||
|
|
||||||
{/* Teams Bot Feature Views */}
|
{/* Teams Bot Feature Views */}
|
||||||
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
<Route path="sessions" element={<FeatureViewPage view="sessions" />} />
|
||||||
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
<Route path="settings" element={<FeatureViewPage view="settings" />} />
|
||||||
|
|
|
||||||
356
src/api/automation2Api.ts
Normal file
356
src/api/automation2Api.ts
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
/** Enriched: run count */
|
||||||
|
runCount?: number;
|
||||||
|
/** Enriched: has active (running/paused) run */
|
||||||
|
isRunning?: boolean;
|
||||||
|
/** Enriched: status of active run */
|
||||||
|
runStatus?: string;
|
||||||
|
/** Enriched: nodeId where workflow is stuck (paused) */
|
||||||
|
stuckAtNodeId?: string;
|
||||||
|
/** Enriched: human-readable label for stuck node */
|
||||||
|
stuckAtNodeLabel?: string;
|
||||||
|
/** Enriched: created timestamp (seconds) */
|
||||||
|
createdAt?: number;
|
||||||
|
/** Enriched: last run started timestamp (seconds) */
|
||||||
|
lastStartedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API FUNCTIONS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => 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>;
|
||||||
|
/** Workflow label (enriched by API) */
|
||||||
|
workflowLabel?: string;
|
||||||
|
/** Unix timestamp ms (from _createdAt) */
|
||||||
|
createdAt?: number;
|
||||||
|
/** Optional due date - configurable in future */
|
||||||
|
dueAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Connections and Browse (for Email/SharePoint node config)
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface UserConnection {
|
||||||
|
id: string;
|
||||||
|
authority: string;
|
||||||
|
externalUsername?: string;
|
||||||
|
externalEmail?: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConnections(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string
|
||||||
|
): Promise<UserConnection[]> {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/automation2/${instanceId}/connections`,
|
||||||
|
method: 'get',
|
||||||
|
});
|
||||||
|
return data?.connections ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConnectionService {
|
||||||
|
service: string;
|
||||||
|
label: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchConnectionServices(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
connectionId: string
|
||||||
|
): Promise<ConnectionService[]> {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/automation2/${instanceId}/connections/${connectionId}/services`,
|
||||||
|
method: 'get',
|
||||||
|
});
|
||||||
|
return data?.services ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowseEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isFolder: boolean;
|
||||||
|
size?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchBrowse(
|
||||||
|
request: ApiRequestFunction,
|
||||||
|
instanceId: string,
|
||||||
|
connectionId: string,
|
||||||
|
service: string,
|
||||||
|
path = '/'
|
||||||
|
): Promise<{ items: BrowseEntry[]; path: string; service: string }> {
|
||||||
|
const data = await request({
|
||||||
|
url: `/api/automation2/${instanceId}/connections/${connectionId}/browse`,
|
||||||
|
method: 'get',
|
||||||
|
params: { service, path },
|
||||||
|
});
|
||||||
|
return { items: data?.items ?? [], path: data?.path ?? path, service: data?.service ?? service };
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,499 @@
|
||||||
|
/**
|
||||||
|
* 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: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
/* Infinite grid: on viewport, moves with pan/zoom via inline style */
|
||||||
|
background-image: radial-gradient(circle, var(--border-color, #e0e0e0) 1px, transparent 1px);
|
||||||
|
background-repeat: repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasContent {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
will-change: transform;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasGrab {
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasPanning {
|
||||||
|
cursor: grabbing;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvasPlaceholder {
|
||||||
|
position: absolute;
|
||||||
|
left: 2rem;
|
||||||
|
top: 2rem;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFieldRemoveButton {
|
||||||
|
margin-left: auto;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formFieldRemoveButton:hover {
|
||||||
|
color: var(--danger-color, #dc3545);
|
||||||
|
background: rgba(220, 53, 69, 0.1);
|
||||||
|
}
|
||||||
328
src/components/Automation2FlowEditor/Automation2FlowEditor.tsx
Normal file
328
src/components/Automation2FlowEditor/Automation2FlowEditor.tsx
Normal file
|
|
@ -0,0 +1,328 @@
|
||||||
|
/**
|
||||||
|
* Automation2FlowEditor
|
||||||
|
*
|
||||||
|
* n8n-style flow builder with backend-driven node list.
|
||||||
|
* Composes: NodeSidebar, FlowCanvas, NodeConfigPanel, CanvasHeader.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { FaSpinner } 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 { NodeSidebar } from './NodeSidebar';
|
||||||
|
import { CanvasHeader } from './CanvasHeader';
|
||||||
|
import { getCategoryIcon } from './utils';
|
||||||
|
import { fromApiGraph, toApiGraph } from './graphUtils';
|
||||||
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
const LOG = '[Automation2]';
|
||||||
|
|
||||||
|
interface Automation2FlowEditorProps {
|
||||||
|
instanceId: string;
|
||||||
|
language?: string;
|
||||||
|
/** When set, load this workflow on mount (e.g. from workflows list edit) */
|
||||||
|
initialWorkflowId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
|
instanceId,
|
||||||
|
language = 'de',
|
||||||
|
initialWorkflowId,
|
||||||
|
}) => {
|
||||||
|
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', 'ai', 'email', 'sharepoint'])
|
||||||
|
);
|
||||||
|
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 handleFromApiGraph = useCallback(
|
||||||
|
(graph: Automation2Graph) => {
|
||||||
|
const { nodes, connections } = fromApiGraph(graph, nodeTypes);
|
||||||
|
setCanvasNodes(nodes);
|
||||||
|
setCanvasConnections(connections);
|
||||||
|
},
|
||||||
|
[nodeTypes]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExecute = useCallback(async () => {
|
||||||
|
const graph = toApiGraph(canvasNodes, canvasConnections);
|
||||||
|
if (graph.nodes.length === 0) {
|
||||||
|
setExecuteResult({ success: false, error: 'Keine Nodes im Workflow.' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setExecuting(true);
|
||||||
|
setExecuteResult(null);
|
||||||
|
try {
|
||||||
|
const result = await executeGraph(
|
||||||
|
request,
|
||||||
|
instanceId,
|
||||||
|
graph,
|
||||||
|
currentWorkflowId ?? undefined
|
||||||
|
);
|
||||||
|
setExecuteResult(result);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
} finally {
|
||||||
|
setExecuting(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId, canvasNodes, canvasConnections, 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(canvasNodes, canvasConnections);
|
||||||
|
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) {
|
||||||
|
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]);
|
||||||
|
|
||||||
|
const handleLoad = useCallback(
|
||||||
|
async (workflowId: string) => {
|
||||||
|
try {
|
||||||
|
const wf = await fetchWorkflow(request, instanceId, workflowId);
|
||||||
|
if (wf.graph) handleFromApiGraph(wf.graph);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setExecuteResult({
|
||||||
|
success: false,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[request, instanceId, handleFromApiGraph]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleWorkflowSelect = useCallback(
|
||||||
|
(workflowId: string | null) => {
|
||||||
|
setCurrentWorkflowId(workflowId);
|
||||||
|
if (workflowId) handleLoad(workflowId);
|
||||||
|
else {
|
||||||
|
setCanvasNodes([]);
|
||||||
|
setCanvasConnections([]);
|
||||||
|
setExecuteResult(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleLoad]
|
||||||
|
);
|
||||||
|
|
||||||
|
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;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const data = await fetchNodeTypes(request, instanceId, language);
|
||||||
|
setNodeTypes(data.nodeTypes);
|
||||||
|
setCategories(data.categories);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
setNodeTypes([]);
|
||||||
|
setCategories([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, language, request]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadNodeTypes();
|
||||||
|
}, [loadNodeTypes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWorkflows();
|
||||||
|
}, [loadWorkflows]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialWorkflowId && workflows.length > 0 && !currentWorkflowId) {
|
||||||
|
handleWorkflowSelect(initialWorkflowId);
|
||||||
|
}
|
||||||
|
}, [initialWorkflowId, workflows, currentWorkflowId, handleWorkflowSelect]);
|
||||||
|
|
||||||
|
const toggleCategory = useCallback((id: string) => {
|
||||||
|
setExpandedCategories((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 renderSidebar = () => {
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<NodeSidebar
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
categories={categories}
|
||||||
|
filter={filter}
|
||||||
|
onFilterChange={setFilter}
|
||||||
|
language={language}
|
||||||
|
expandedCategories={expandedCategories}
|
||||||
|
onToggleCategory={toggleCategory}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
{renderSidebar()}
|
||||||
|
|
||||||
|
<div className={styles.canvas}>
|
||||||
|
<CanvasHeader
|
||||||
|
workflows={workflows}
|
||||||
|
currentWorkflowId={currentWorkflowId}
|
||||||
|
onWorkflowSelect={handleWorkflowSelect}
|
||||||
|
onNew={handleNew}
|
||||||
|
onSave={handleSave}
|
||||||
|
onExecute={handleExecute}
|
||||||
|
saving={saving}
|
||||||
|
executing={executing}
|
||||||
|
hasNodes={canvasNodes.length > 0}
|
||||||
|
executeResult={executeResult}
|
||||||
|
/>
|
||||||
|
<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 &&
|
||||||
|
['input.', 'ai.', 'email.', 'sharepoint.'].some((p) =>
|
||||||
|
selectedNode.type.startsWith(p)
|
||||||
|
) && (
|
||||||
|
<NodeConfigPanel
|
||||||
|
node={selectedNode}
|
||||||
|
nodeType={nodeTypes.find((nt) => nt.id === selectedNode.type)}
|
||||||
|
language={language}
|
||||||
|
onParametersChange={handleNodeParametersChange}
|
||||||
|
instanceId={instanceId}
|
||||||
|
request={request}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Automation2FlowEditor;
|
||||||
117
src/components/Automation2FlowEditor/CanvasHeader.tsx
Normal file
117
src/components/Automation2FlowEditor/CanvasHeader.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
/**
|
||||||
|
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen) and execute result.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FaPlay, FaSpinner } from 'react-icons/fa';
|
||||||
|
import type { Automation2Workflow, ExecuteGraphResponse } from '../../api/automation2Api';
|
||||||
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
interface CanvasHeaderProps {
|
||||||
|
workflows: Automation2Workflow[];
|
||||||
|
currentWorkflowId: string | null;
|
||||||
|
onWorkflowSelect: (workflowId: string | null) => void;
|
||||||
|
onNew: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onExecute: () => void;
|
||||||
|
saving: boolean;
|
||||||
|
executing: boolean;
|
||||||
|
hasNodes: boolean;
|
||||||
|
executeResult: ExecuteGraphResponse | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
|
||||||
|
workflows,
|
||||||
|
currentWorkflowId,
|
||||||
|
onWorkflowSelect,
|
||||||
|
onNew,
|
||||||
|
onSave,
|
||||||
|
onExecute,
|
||||||
|
saving,
|
||||||
|
executing,
|
||||||
|
hasNodes,
|
||||||
|
executeResult,
|
||||||
|
}) => (
|
||||||
|
<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={onNew}>
|
||||||
|
Neu
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.retryButton}
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving || !hasNodes}
|
||||||
|
>
|
||||||
|
{saving ? <FaSpinner className={styles.spinner} /> : 'Speichern'}
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
value={currentWorkflowId ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.target.value ? e.target.value : null;
|
||||||
|
onWorkflowSelect(id);
|
||||||
|
}}
|
||||||
|
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={onExecute}
|
||||||
|
disabled={executing || !hasNodes}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
579
src/components/Automation2FlowEditor/FlowCanvas.tsx
Normal file
579
src/components/Automation2FlowEditor/FlowCanvas.tsx
Normal file
|
|
@ -0,0 +1,579 @@
|
||||||
|
/**
|
||||||
|
* 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({
|
||||||
|
startClientX: 0,
|
||||||
|
startClientY: 0,
|
||||||
|
startNodeX: 0,
|
||||||
|
startNodeY: 0,
|
||||||
|
});
|
||||||
|
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 });
|
||||||
|
const [zoom, setZoom] = useState(1);
|
||||||
|
const [panning, setPanning] = useState<{
|
||||||
|
startX: number;
|
||||||
|
startY: number;
|
||||||
|
startPanX: number;
|
||||||
|
startPanY: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
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 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 - panOffset.x) / zoom - NODE_WIDTH / 2;
|
||||||
|
const y = (e.clientY - rect.top - panOffset.y) / zoom - NODE_HEIGHT / 2;
|
||||||
|
onDropNodeType(type, Math.max(0, x), Math.max(0, y));
|
||||||
|
} catch (_) {}
|
||||||
|
},
|
||||||
|
[onDropNodeType, panOffset, zoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
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({
|
||||||
|
startClientX: e.clientX,
|
||||||
|
startClientY: e.clientY,
|
||||||
|
startNodeX: node.x,
|
||||||
|
startNodeY: node.y,
|
||||||
|
});
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!draggingNodeId) return;
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
const dx = (e.clientX - dragOffset.startClientX) / zoom;
|
||||||
|
const dy = (e.clientY - dragOffset.startClientY) / zoom;
|
||||||
|
onNodesChange(
|
||||||
|
nodes.map((n) =>
|
||||||
|
n.id === draggingNodeId
|
||||||
|
? { ...n, x: dragOffset.startNodeX + dx, y: dragOffset.startNodeY + 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, zoom]);
|
||||||
|
|
||||||
|
const handleCanvasMouseDown = useCallback((e: React.MouseEvent) => {
|
||||||
|
const hitNode = (e.target as HTMLElement).closest(`.${styles.canvasNode}`);
|
||||||
|
if (hitNode || connectingFrom) return;
|
||||||
|
setPanning({
|
||||||
|
startX: e.clientX,
|
||||||
|
startY: e.clientY,
|
||||||
|
startPanX: panOffset.x,
|
||||||
|
startPanY: panOffset.y,
|
||||||
|
});
|
||||||
|
}, [connectingFrom, panOffset]);
|
||||||
|
|
||||||
|
const handleWheel = useCallback((e: WheelEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const delta = e.deltaY > 0 ? -0.1 : 0.1;
|
||||||
|
setZoom((z) => Math.min(2, Math.max(0.25, z + delta)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('wheel', handleWheel, { passive: false });
|
||||||
|
return () => el.removeEventListener('wheel', handleWheel);
|
||||||
|
}, [handleWheel]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!panning) return;
|
||||||
|
const onMove = (e: MouseEvent) => {
|
||||||
|
setPanOffset({
|
||||||
|
x: panning.startPanX + (e.clientX - panning.startX),
|
||||||
|
y: panning.startPanY + (e.clientY - panning.startY),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const onUp = () => setPanning(null);
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
}, [panning]);
|
||||||
|
|
||||||
|
const [containerBounds, setContainerBounds] = useState({ left: 0, top: 0 });
|
||||||
|
React.useEffect(() => {
|
||||||
|
const el = containerRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const update = () => {
|
||||||
|
const r = el.getBoundingClientRect();
|
||||||
|
setContainerBounds({ left: r.left, top: r.top });
|
||||||
|
};
|
||||||
|
update();
|
||||||
|
window.addEventListener('resize', update);
|
||||||
|
return () => window.removeEventListener('resize', update);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const CANVAS_SIZE = 8000;
|
||||||
|
const svgBounds = useMemo(() => {
|
||||||
|
if (nodes.length === 0) return { width: CANVAS_SIZE, height: CANVAS_SIZE };
|
||||||
|
let maxX = 0, maxY = 0;
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
maxX = Math.max(maxX, n.x + NODE_WIDTH + 200);
|
||||||
|
maxY = Math.max(maxY, n.y + NODE_HEIGHT + 200);
|
||||||
|
});
|
||||||
|
return { width: Math.max(maxX, CANVAS_SIZE), height: Math.max(maxY, CANVAS_SIZE) };
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
const screenToSvg = useCallback(
|
||||||
|
(clientX: number, clientY: number) => ({
|
||||||
|
x: (clientX - containerBounds.left - panOffset.x) / zoom,
|
||||||
|
y: (clientY - containerBounds.top - panOffset.y) / zoom,
|
||||||
|
}),
|
||||||
|
[containerBounds, panOffset, zoom]
|
||||||
|
);
|
||||||
|
|
||||||
|
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} ${panning ? styles.canvasPanning : styles.canvasGrab}`}
|
||||||
|
style={{
|
||||||
|
backgroundSize: `${20 * zoom}px ${20 * zoom}px`,
|
||||||
|
backgroundPosition: `${-panOffset.x}px ${-panOffset.y}px`,
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onMouseDown={handleCanvasMouseDown}
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => setSelectedNodeId(null)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.canvasContent}
|
||||||
|
style={{
|
||||||
|
width: svgBounds.width,
|
||||||
|
height: svgBounds.height,
|
||||||
|
transform: `translate(${panOffset.x}px, ${panOffset.y}px) scale(${zoom})`,
|
||||||
|
transformOrigin: '0 0',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 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>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
70
src/components/Automation2FlowEditor/NodeConfigPanel.tsx
Normal file
70
src/components/Automation2FlowEditor/NodeConfigPanel.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
/**
|
||||||
|
* NodeConfigPanel - Configures parameters for input, ai, email, sharepoint nodes.
|
||||||
|
* Delegates to config components from configs/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import type { CanvasNode } from './FlowCanvas';
|
||||||
|
import type { NodeType } from '../../api/automation2Api';
|
||||||
|
import type { ApiRequestFunction } from '../../api/automation2Api';
|
||||||
|
import { getLabel } from './utils';
|
||||||
|
import { NODE_CONFIG_REGISTRY } from './configs';
|
||||||
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
interface NodeConfigPanelProps {
|
||||||
|
node: CanvasNode | null;
|
||||||
|
nodeType: NodeType | undefined;
|
||||||
|
language: string;
|
||||||
|
onParametersChange: (nodeId: string, parameters: Record<string, unknown>) => void;
|
||||||
|
instanceId?: string;
|
||||||
|
request?: ApiRequestFunction;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIGURABLE_PREFIXES = ['input.', 'ai.', 'email.', 'sharepoint.'];
|
||||||
|
|
||||||
|
export const NodeConfigPanel: React.FC<NodeConfigPanelProps> = ({
|
||||||
|
node,
|
||||||
|
nodeType,
|
||||||
|
language,
|
||||||
|
onParametersChange,
|
||||||
|
instanceId,
|
||||||
|
request,
|
||||||
|
}) => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isConfigurable = node && CONFIGURABLE_PREFIXES.some((p) => node.type.startsWith(p));
|
||||||
|
if (!node || !isConfigurable) return null;
|
||||||
|
|
||||||
|
const ConfigRenderer = NODE_CONFIG_REGISTRY[node.type];
|
||||||
|
if (!ConfigRenderer) {
|
||||||
|
return (
|
||||||
|
<div className={styles.nodeConfigPanel}>
|
||||||
|
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
|
||||||
|
<p>No configuration for {node.type}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.nodeConfigPanel}>
|
||||||
|
<h4>{getLabel(nodeType?.label, language) || node.type}</h4>
|
||||||
|
<ConfigRenderer
|
||||||
|
params={params}
|
||||||
|
updateParam={updateParam}
|
||||||
|
instanceId={instanceId}
|
||||||
|
request={request}
|
||||||
|
nodeType={node.type}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
49
src/components/Automation2FlowEditor/NodeListItem.tsx
Normal file
49
src/components/Automation2FlowEditor/NodeListItem.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* NodeListItem - Draggable node type item for the sidebar.
|
||||||
|
* Used in both regular categories and I/O sub-groups.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeType } from '../../api/automation2Api';
|
||||||
|
import { getCategoryIcon } from './utils';
|
||||||
|
import type { GetLabelFn } from './utils';
|
||||||
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
interface NodeListItemProps {
|
||||||
|
node: NodeType;
|
||||||
|
language: string;
|
||||||
|
getLabel: GetLabelFn;
|
||||||
|
getCategoryIcon?: (categoryId: string) => React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NodeListItem: React.FC<NodeListItemProps> = ({
|
||||||
|
node,
|
||||||
|
language,
|
||||||
|
getLabel,
|
||||||
|
getCategoryIcon: getIcon = getCategoryIcon,
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
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)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{getIcon(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>
|
||||||
|
);
|
||||||
121
src/components/Automation2FlowEditor/NodeSidebar.tsx
Normal file
121
src/components/Automation2FlowEditor/NodeSidebar.tsx
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
/**
|
||||||
|
* NodeSidebar - Sidebar with searchable, collapsible node list.
|
||||||
|
* Groups node types by category (trigger, input, flow, data, ai, email, sharepoint).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
||||||
|
import type { NodeType, NodeTypeCategory } from '../../api/automation2Api';
|
||||||
|
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from './constants';
|
||||||
|
import { getLabel } from './utils';
|
||||||
|
import { NodeListItem } from './NodeListItem';
|
||||||
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
interface NodeSidebarProps {
|
||||||
|
nodeTypes: NodeType[];
|
||||||
|
categories: NodeTypeCategory[];
|
||||||
|
filter: string;
|
||||||
|
onFilterChange: (value: string) => void;
|
||||||
|
language: string;
|
||||||
|
expandedCategories: Set<string>;
|
||||||
|
onToggleCategory: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NodeSidebar: React.FC<NodeSidebarProps> = ({
|
||||||
|
nodeTypes,
|
||||||
|
categories,
|
||||||
|
filter,
|
||||||
|
onFilterChange,
|
||||||
|
language,
|
||||||
|
expandedCategories,
|
||||||
|
onToggleCategory,
|
||||||
|
}) => {
|
||||||
|
const filteredNodeTypes = useMemo(() => {
|
||||||
|
const visible = nodeTypes.filter((n) => !HIDDEN_NODE_IDS.has(n.id));
|
||||||
|
if (!filter.trim()) return visible;
|
||||||
|
const q = filter.toLowerCase();
|
||||||
|
return visible.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]);
|
||||||
|
|
||||||
|
const orderedCategories = useMemo(() => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: string[] = [];
|
||||||
|
CATEGORY_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]);
|
||||||
|
|
||||||
|
const getLabelFn = (t: string | Record<string, string> | undefined, lang?: string) =>
|
||||||
|
getLabel(t, lang ?? language);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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) => onFilterChange(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;
|
||||||
|
const items = groupedByCategory[catId] || [];
|
||||||
|
return (
|
||||||
|
<div key={catId} className={styles.categoryGroup}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.categoryHeader}
|
||||||
|
onClick={() => onToggleCategory(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) => (
|
||||||
|
<NodeListItem
|
||||||
|
key={node.id}
|
||||||
|
node={node}
|
||||||
|
language={language}
|
||||||
|
getLabel={getLabelFn}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
19
src/components/Automation2FlowEditor/categoryIcons.tsx
Normal file
19
src/components/Automation2FlowEditor/categoryIcons.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
/**
|
||||||
|
* Category icons for node types
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FaPlay, FaCodeBranch, FaDatabase, FaPlug, FaUser, FaRobot, FaEnvelope, FaCloud } from 'react-icons/fa';
|
||||||
|
|
||||||
|
export const CATEGORY_ICONS: Record<string, React.ReactNode> = {
|
||||||
|
trigger: <FaPlay />,
|
||||||
|
input: <FaUser />,
|
||||||
|
flow: <FaCodeBranch />,
|
||||||
|
data: <FaDatabase />,
|
||||||
|
ai: <FaRobot />,
|
||||||
|
email: <FaEnvelope />,
|
||||||
|
sharepoint: <FaCloud />,
|
||||||
|
human: <FaUser />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_CATEGORY_ICON = <FaPlug />;
|
||||||
|
|
@ -0,0 +1,68 @@
|
||||||
|
/**
|
||||||
|
* AI node config - prompt, query, document options per node type.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
const AI_FIELD_CONFIG: Record<string, { label: string; key: string; type: 'textarea' | 'input' | 'select'; options?: string[] }[]> = {
|
||||||
|
'ai.prompt': [
|
||||||
|
{ label: 'Prompt', key: 'prompt', type: 'textarea' },
|
||||||
|
{ label: 'Output format', key: 'resultType', type: 'select', options: ['txt', 'json', 'md', 'html', 'csv'] },
|
||||||
|
],
|
||||||
|
'ai.webResearch': [{ label: 'Query', key: 'query', type: 'textarea' }],
|
||||||
|
'ai.summarizeDocument': [
|
||||||
|
{ label: 'Summary length', key: 'summaryLength', type: 'select', options: ['short', 'medium', 'long'] },
|
||||||
|
],
|
||||||
|
'ai.translateDocument': [{ label: 'Target language', key: 'targetLanguage', type: 'input' }],
|
||||||
|
'ai.convertDocument': [
|
||||||
|
{ label: 'Target format', key: 'targetFormat', type: 'select', options: ['pdf', 'docx', 'txt', 'md'] },
|
||||||
|
],
|
||||||
|
'ai.generateDocument': [
|
||||||
|
{ label: 'Prompt', key: 'prompt', type: 'textarea' },
|
||||||
|
{ label: 'Format', key: 'format', type: 'select', options: ['docx', 'txt', 'md'] },
|
||||||
|
],
|
||||||
|
'ai.generateCode': [
|
||||||
|
{ label: 'Prompt', key: 'prompt', type: 'textarea' },
|
||||||
|
{ label: 'Language', key: 'language', type: 'select', options: ['python', 'javascript', 'typescript', 'sql'] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AiNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam, nodeType = 'ai.prompt' }) => {
|
||||||
|
const fields = AI_FIELD_CONFIG[nodeType] ?? AI_FIELD_CONFIG['ai.prompt'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{fields.map((f) => (
|
||||||
|
<div key={f.key}>
|
||||||
|
<label>{f.label}</label>
|
||||||
|
{f.type === 'textarea' ? (
|
||||||
|
<textarea
|
||||||
|
value={(params[f.key] as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam(f.key, e.target.value)}
|
||||||
|
placeholder={f.label}
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
) : f.type === 'select' ? (
|
||||||
|
<select
|
||||||
|
value={(params[f.key] as string) ?? (f.options?.[0] ?? '')}
|
||||||
|
onChange={(e) => updateParam(f.key, e.target.value)}
|
||||||
|
>
|
||||||
|
{(f.options ?? []).map((opt) => (
|
||||||
|
<option key={opt} value={opt}>
|
||||||
|
{opt}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
value={(params[f.key] as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam(f.key, e.target.value)}
|
||||||
|
placeholder={f.label}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/**
|
||||||
|
* Approval node config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
export const ApprovalNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/**
|
||||||
|
* Comment node config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
export const CommentNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
/**
|
||||||
|
* Confirmation node config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
export const ConfirmationNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
240
src/components/Automation2FlowEditor/configs/EmailNodeConfig.tsx
Normal file
240
src/components/Automation2FlowEditor/configs/EmailNodeConfig.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
||||||
|
/**
|
||||||
|
* Email node config - connection selector, folder dropdown, query, subject, body.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../api/automation2Api';
|
||||||
|
|
||||||
|
export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
|
params,
|
||||||
|
updateParam,
|
||||||
|
instanceId,
|
||||||
|
request,
|
||||||
|
nodeType = 'email.checkEmail',
|
||||||
|
}) => {
|
||||||
|
const [connections, setConnections] = useState<UserConnection[]>([]);
|
||||||
|
const [folders, setFolders] = useState<BrowseEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [foldersLoading, setFoldersLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (instanceId && request) {
|
||||||
|
setLoading(true);
|
||||||
|
fetchConnections(request, instanceId)
|
||||||
|
.then(setConnections)
|
||||||
|
.catch(() => setConnections([]))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
const connectionId = (params.connectionId as string) ?? '';
|
||||||
|
const selectedConn = connections.find((c) => c.id === connectionId);
|
||||||
|
const mailService = selectedConn?.authority === 'google' ? 'gmail' : 'outlook';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (instanceId && request && connectionId) {
|
||||||
|
setFoldersLoading(true);
|
||||||
|
fetchBrowse(request, instanceId, connectionId, mailService, '/')
|
||||||
|
.then((r) => setFolders(r.items.filter((e) => e.isFolder)))
|
||||||
|
.catch(() => setFolders([]))
|
||||||
|
.finally(() => setFoldersLoading(false));
|
||||||
|
} else {
|
||||||
|
setFolders([]);
|
||||||
|
}
|
||||||
|
}, [instanceId, request, connectionId, mailService]);
|
||||||
|
|
||||||
|
const isDraft = nodeType === 'email.draftEmail';
|
||||||
|
const isSearch = nodeType === 'email.searchEmail';
|
||||||
|
const folderValue = (params.folder as string) ?? (isSearch ? 'All' : 'Inbox');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Account</label>
|
||||||
|
<select
|
||||||
|
value={connectionId}
|
||||||
|
onChange={(e) => updateParam('connectionId', e.target.value)}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<option value="">{loading ? 'Loading...' : 'Select connection'}</option>
|
||||||
|
{connections.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.externalEmail ?? c.externalUsername ?? c.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{!isDraft && (
|
||||||
|
<div>
|
||||||
|
<label>Folder</label>
|
||||||
|
<select
|
||||||
|
value={folderValue}
|
||||||
|
onChange={(e) => updateParam('folder', e.target.value)}
|
||||||
|
disabled={foldersLoading || !connectionId}
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{foldersLoading ? 'Loading folders...' : !connectionId ? 'Select account first' : 'Select folder'}
|
||||||
|
</option>
|
||||||
|
{isSearch && <option value="All">All</option>}
|
||||||
|
{folders.length > 0
|
||||||
|
? folders.map((f) => {
|
||||||
|
const folderId = (f.path ?? '').replace(/^\//, '') || (f.metadata as { id?: string })?.id || '';
|
||||||
|
const value = folderId || f.name;
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<option key={value} value={value}>
|
||||||
|
{f.name}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: !isSearch && (
|
||||||
|
<>
|
||||||
|
<option value="Inbox">Inbox</option>
|
||||||
|
<option value="Drafts">Drafts</option>
|
||||||
|
<option value="SentItems">Sent Items</option>
|
||||||
|
<option value="DeletedItems">Deleted Items</option>
|
||||||
|
<option value="JunkEmail">Junk Email</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{folderValue &&
|
||||||
|
!folders.some(
|
||||||
|
(f) =>
|
||||||
|
((f.path ?? '').replace(/^\//, '') || (f.metadata as { id?: string })?.id) === folderValue
|
||||||
|
) &&
|
||||||
|
folderValue !== 'All' && (
|
||||||
|
<option value={folderValue}>{folderValue}</option>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSearch && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Search query (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.query as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('query', e.target.value)}
|
||||||
|
placeholder="General search term (subject, body, from)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>From address (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.fromAddress as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('fromAddress', e.target.value)}
|
||||||
|
placeholder="e.g. sender@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>To address (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.toAddress as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('toAddress', e.target.value)}
|
||||||
|
placeholder="e.g. recipient@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Subject contains (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.subjectContains as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('subjectContains', e.target.value)}
|
||||||
|
placeholder="Word or phrase in subject"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Body/content contains (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.bodyContains as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('bodyContains', e.target.value)}
|
||||||
|
placeholder="Word or phrase in email body"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="searchHasAttachment"
|
||||||
|
checked={!!(params.hasAttachment as boolean)}
|
||||||
|
onChange={(e) => updateParam('hasAttachment', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="searchHasAttachment">Only emails with attachment</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Limit</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={(params.limit as number) ?? 100}
|
||||||
|
onChange={(e) => updateParam('limit', parseInt(e.target.value, 10) || 100)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{nodeType === 'email.checkEmail' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>From address (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.fromAddress as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('fromAddress', e.target.value)}
|
||||||
|
placeholder="e.g. sender@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Subject contains (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.subjectContains as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('subjectContains', e.target.value)}
|
||||||
|
placeholder="Word or phrase in subject"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="hasAttachment"
|
||||||
|
checked={!!(params.hasAttachment as boolean)}
|
||||||
|
onChange={(e) => updateParam('hasAttachment', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<label htmlFor="hasAttachment">Only emails with attachment</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Limit</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={(params.limit as number) ?? 100}
|
||||||
|
onChange={(e) => updateParam('limit', parseInt(e.target.value, 10) || 100)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{isDraft && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Subject</label>
|
||||||
|
<input
|
||||||
|
value={(params.subject as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('subject', e.target.value)}
|
||||||
|
placeholder="Email subject (or leave empty if connected to AI node above)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Body</label>
|
||||||
|
<textarea
|
||||||
|
value={(params.body as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('body', e.target.value)}
|
||||||
|
placeholder="Email body (or leave empty if connected to AI node above)"
|
||||||
|
rows={4}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>To (optional)</label>
|
||||||
|
<input
|
||||||
|
value={(params.to as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('to', e.target.value)}
|
||||||
|
placeholder="Recipient(s) (or from AI when connected)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
126
src/components/Automation2FlowEditor/configs/FormNodeConfig.tsx
Normal file
126
src/components/Automation2FlowEditor/configs/FormNodeConfig.tsx
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
/**
|
||||||
|
* Form node config - draggable fields, types, required toggle
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
||||||
|
import type { FormField, NodeConfigRendererProps } from './types';
|
||||||
|
import styles from '../Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
|
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeField = (index: number) => {
|
||||||
|
const next = fields.filter((_, i) => i !== index);
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeField(i)}
|
||||||
|
title="Feld entfernen"
|
||||||
|
className={styles.formFieldRemoveButton}
|
||||||
|
>
|
||||||
|
<FaTimes />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
updateParam('fields', [...fields, { name: '', type: 'string', label: '', required: false }])
|
||||||
|
}
|
||||||
|
>
|
||||||
|
+ Feld
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
/**
|
||||||
|
* Review node config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
export const ReviewNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||||
|
<div>
|
||||||
|
<label>Content-Referenz</label>
|
||||||
|
<input
|
||||||
|
value={(params.contentRef as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('contentRef', e.target.value)}
|
||||||
|
placeholder="{{nodeId.field}}"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* Selection node config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
export const SelectionNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,245 @@
|
||||||
|
/**
|
||||||
|
* SharePoint node config - connection selector, path, search query.
|
||||||
|
* Uses SharepointBrowseTree (FolderTree-style) for file selection.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../api/automation2Api';
|
||||||
|
import { SharepointBrowseTree } from '../../FolderTree/SharepointBrowseTree';
|
||||||
|
|
||||||
|
export const SharePointNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
|
params,
|
||||||
|
updateParam,
|
||||||
|
instanceId,
|
||||||
|
request,
|
||||||
|
nodeType = 'sharepoint.findFile',
|
||||||
|
}) => {
|
||||||
|
const [connections, setConnections] = useState<UserConnection[]>([]);
|
||||||
|
const [browseExpanded, setBrowseExpanded] = useState(false);
|
||||||
|
const [copySourceExpanded, setCopySourceExpanded] = useState(false);
|
||||||
|
const [copyDestExpanded, setCopyDestExpanded] = useState(false);
|
||||||
|
const [connectionsLoading, setConnectionsLoading] = useState(false);
|
||||||
|
|
||||||
|
const connectionId = (params.connectionId as string) ?? '';
|
||||||
|
const pathParam = 'path';
|
||||||
|
const path = (params.path as string) ?? (params.filePath as string) ?? '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (instanceId && request) {
|
||||||
|
setConnectionsLoading(true);
|
||||||
|
fetchConnections(request, instanceId)
|
||||||
|
.then(setConnections)
|
||||||
|
.catch(() => setConnections([]))
|
||||||
|
.finally(() => setConnectionsLoading(false));
|
||||||
|
}
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
const loadChildren = useCallback(
|
||||||
|
async (pathToLoad: string): Promise<BrowseEntry[]> => {
|
||||||
|
if (!instanceId || !request || !connectionId) return [];
|
||||||
|
const r = await fetchBrowse(request, instanceId, connectionId, 'sharepoint', pathToLoad);
|
||||||
|
return r?.items ?? [];
|
||||||
|
},
|
||||||
|
[instanceId, request, connectionId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectPath = useCallback(
|
||||||
|
(p: string) => {
|
||||||
|
updateParam(pathParam, p);
|
||||||
|
setBrowseExpanded(false);
|
||||||
|
},
|
||||||
|
[updateParam, pathParam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectSourcePath = useCallback(
|
||||||
|
(p: string) => {
|
||||||
|
updateParam('sourcePath', p);
|
||||||
|
setCopySourceExpanded(false);
|
||||||
|
},
|
||||||
|
[updateParam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectDestPath = useCallback(
|
||||||
|
(p: string) => {
|
||||||
|
updateParam('destPath', p);
|
||||||
|
setCopyDestExpanded(false);
|
||||||
|
},
|
||||||
|
[updateParam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const needsPath = !['sharepoint.findFile'].includes(nodeType);
|
||||||
|
const needsSearch = nodeType === 'sharepoint.findFile';
|
||||||
|
const needsSiteId = false;
|
||||||
|
const hasPathInput = ['sharepoint.readFile', 'sharepoint.uploadFile', 'sharepoint.downloadFile', 'sharepoint.copyFile'].includes(nodeType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Connection</label>
|
||||||
|
<select
|
||||||
|
value={connectionId}
|
||||||
|
onChange={(e) => updateParam('connectionId', e.target.value)}
|
||||||
|
disabled={connectionsLoading}
|
||||||
|
>
|
||||||
|
<option value="">{connectionsLoading ? 'Loading...' : 'Select connection'}</option>
|
||||||
|
{connections.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.externalUsername ?? c.id}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{needsSearch && (
|
||||||
|
<div>
|
||||||
|
<label>Search query / path</label>
|
||||||
|
<input
|
||||||
|
value={(params.searchQuery as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('searchQuery', e.target.value)}
|
||||||
|
placeholder="/sites/SiteName/Shared Documents or search term"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{needsPath && nodeType === 'sharepoint.listFiles' && (
|
||||||
|
<div>
|
||||||
|
<label>Folder path</label>
|
||||||
|
<input
|
||||||
|
value={path}
|
||||||
|
onChange={(e) => updateParam('path', e.target.value)}
|
||||||
|
placeholder="/ or /sites/SiteName/Shared Documents/Folder"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{needsPath && ['sharepoint.readFile', 'sharepoint.uploadFile', 'sharepoint.downloadFile'].includes(nodeType) && (
|
||||||
|
<div>
|
||||||
|
<label>{nodeType === 'sharepoint.uploadFile' ? 'Target folder path' : 'Path'}</label>
|
||||||
|
<input
|
||||||
|
value={(params.path as string) ?? (params.filePath as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('path', e.target.value)}
|
||||||
|
placeholder={
|
||||||
|
nodeType === 'sharepoint.downloadFile'
|
||||||
|
? '/sites/SiteName/Shared Documents/file.pdf'
|
||||||
|
: nodeType === 'sharepoint.uploadFile'
|
||||||
|
? '/sites/.../Shared Documents/TargetFolder/'
|
||||||
|
: 'File or folder path'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{needsSiteId && (
|
||||||
|
<div>
|
||||||
|
<label>Site ID</label>
|
||||||
|
<input
|
||||||
|
value={(params.siteId as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('siteId', e.target.value)}
|
||||||
|
placeholder="SharePoint site ID"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{nodeType === 'sharepoint.copyFile' && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label>Source file</label>
|
||||||
|
<input
|
||||||
|
value={(params.sourcePath as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('sourcePath', e.target.value)}
|
||||||
|
placeholder="/sites/.../folder/file.pdf"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Destination folder</label>
|
||||||
|
<input
|
||||||
|
value={(params.destPath as string) ?? ''}
|
||||||
|
onChange={(e) => updateParam('destPath', e.target.value)}
|
||||||
|
placeholder="/sites/.../target-folder/"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{connectionId && (
|
||||||
|
<>
|
||||||
|
<details
|
||||||
|
open={copySourceExpanded}
|
||||||
|
onToggle={(e) => setCopySourceExpanded((e.target as HTMLDetailsElement).open)}
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'var(--bg-secondary, #f8f9fa)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<summary style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', fontWeight: 500, fontSize: '0.875rem' }}>
|
||||||
|
📂 Source file durchsuchen
|
||||||
|
</summary>
|
||||||
|
<div style={{ padding: '0.5rem 0.75rem', borderTop: '1px solid var(--border-color, #e0e0e0)', maxHeight: 280, overflowY: 'auto' }}>
|
||||||
|
<SharepointBrowseTree rootPath="/" onLoadChildren={loadChildren} onSelectFile={selectSourcePath} selectedPath={(params.sourcePath as string) || null} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details
|
||||||
|
open={copyDestExpanded}
|
||||||
|
onToggle={(e) => setCopyDestExpanded((e.target as HTMLDetailsElement).open)}
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'var(--bg-secondary, #f8f9fa)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<summary style={{ padding: '0.5rem 0.75rem', cursor: 'pointer', fontWeight: 500, fontSize: '0.875rem' }}>
|
||||||
|
📂 Zielordner durchsuchen
|
||||||
|
</summary>
|
||||||
|
<div style={{ padding: '0.5rem 0.75rem', borderTop: '1px solid var(--border-color, #e0e0e0)', maxHeight: 280, overflowY: 'auto' }}>
|
||||||
|
<SharepointBrowseTree rootPath="/" onLoadChildren={loadChildren} onSelectFile={() => {}} onSelectFolder={selectDestPath} selectedPath={(params.destPath as string) || null} />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{connectionId && needsPath && hasPathInput && !['sharepoint.copyFile'].includes(nodeType) && (
|
||||||
|
<details
|
||||||
|
open={browseExpanded}
|
||||||
|
onToggle={(e) => setBrowseExpanded((e.target as HTMLDetailsElement).open)}
|
||||||
|
style={{
|
||||||
|
marginTop: 12,
|
||||||
|
border: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'var(--bg-secondary, #f8f9fa)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: '0.875rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.5rem',
|
||||||
|
userSelect: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ opacity: browseExpanded ? 0.7 : 1 }}>📂</span>
|
||||||
|
SharePoint durchsuchen
|
||||||
|
</summary>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
padding: '0.5rem 0.75rem',
|
||||||
|
borderTop: '1px solid var(--border-color, #e0e0e0)',
|
||||||
|
maxHeight: 280,
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SharepointBrowseTree
|
||||||
|
rootPath="/"
|
||||||
|
onLoadChildren={loadChildren}
|
||||||
|
onSelectFile={selectPath}
|
||||||
|
selectedPath={path || null}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* Upload node config
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
|
||||||
|
export const UploadNodeConfig: React.FC<NodeConfigRendererProps> = ({ params, updateParam }) => (
|
||||||
|
<>
|
||||||
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
44
src/components/Automation2FlowEditor/configs/index.ts
Normal file
44
src/components/Automation2FlowEditor/configs/index.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
/**
|
||||||
|
* Node config renderers - one per node type (input, ai, email, sharepoint).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ComponentType } from 'react';
|
||||||
|
import type { NodeConfigRendererProps } from './types';
|
||||||
|
import { FormNodeConfig } from './FormNodeConfig';
|
||||||
|
import { ApprovalNodeConfig } from './ApprovalNodeConfig';
|
||||||
|
import { UploadNodeConfig } from './UploadNodeConfig';
|
||||||
|
import { CommentNodeConfig } from './CommentNodeConfig';
|
||||||
|
import { ReviewNodeConfig } from './ReviewNodeConfig';
|
||||||
|
import { SelectionNodeConfig } from './SelectionNodeConfig';
|
||||||
|
import { ConfirmationNodeConfig } from './ConfirmationNodeConfig';
|
||||||
|
import { AiNodeConfig } from './AiNodeConfig';
|
||||||
|
import { EmailNodeConfig } from './EmailNodeConfig';
|
||||||
|
import { SharePointNodeConfig } from './SharePointNodeConfig';
|
||||||
|
|
||||||
|
export type NodeConfigComponent = ComponentType<NodeConfigRendererProps>;
|
||||||
|
|
||||||
|
export const NODE_CONFIG_REGISTRY: Record<string, NodeConfigComponent> = {
|
||||||
|
'input.form': FormNodeConfig,
|
||||||
|
'input.approval': ApprovalNodeConfig,
|
||||||
|
'input.upload': UploadNodeConfig,
|
||||||
|
'input.comment': CommentNodeConfig,
|
||||||
|
'input.review': ReviewNodeConfig,
|
||||||
|
'input.selection': SelectionNodeConfig,
|
||||||
|
'input.confirmation': ConfirmationNodeConfig,
|
||||||
|
'ai.prompt': AiNodeConfig,
|
||||||
|
'ai.webResearch': AiNodeConfig,
|
||||||
|
'ai.summarizeDocument': AiNodeConfig,
|
||||||
|
'ai.translateDocument': AiNodeConfig,
|
||||||
|
'ai.convertDocument': AiNodeConfig,
|
||||||
|
'ai.generateDocument': AiNodeConfig,
|
||||||
|
'ai.generateCode': AiNodeConfig,
|
||||||
|
'email.checkEmail': EmailNodeConfig,
|
||||||
|
'email.searchEmail': EmailNodeConfig,
|
||||||
|
'email.draftEmail': EmailNodeConfig,
|
||||||
|
'sharepoint.findFile': SharePointNodeConfig,
|
||||||
|
'sharepoint.readFile': SharePointNodeConfig,
|
||||||
|
'sharepoint.uploadFile': SharePointNodeConfig,
|
||||||
|
'sharepoint.listFiles': SharePointNodeConfig,
|
||||||
|
'sharepoint.downloadFile': SharePointNodeConfig,
|
||||||
|
'sharepoint.copyFile': SharePointNodeConfig,
|
||||||
|
};
|
||||||
16
src/components/Automation2FlowEditor/configs/types.ts
Normal file
16
src/components/Automation2FlowEditor/configs/types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* Shared types for node config renderers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ApiRequestFunction } from '../../../api/automation2Api';
|
||||||
|
|
||||||
|
export type FormField = { name?: string; type?: string; label?: string; required?: boolean };
|
||||||
|
|
||||||
|
export interface NodeConfigRendererProps {
|
||||||
|
params: Record<string, unknown>;
|
||||||
|
updateParam: (key: string, value: unknown) => void;
|
||||||
|
/** For Email/SharePoint: fetch connections and browse */
|
||||||
|
instanceId?: string;
|
||||||
|
request?: ApiRequestFunction;
|
||||||
|
nodeType?: string;
|
||||||
|
}
|
||||||
38
src/components/Automation2FlowEditor/constants.ts
Normal file
38
src/components/Automation2FlowEditor/constants.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* Automation2 Flow Editor - Constants
|
||||||
|
* Category ordering for node sidebar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Node type IDs hidden from the sidebar (hidden, not removed – still work when present in saved graphs) */
|
||||||
|
export const HIDDEN_NODE_IDS = new Set([
|
||||||
|
'trigger.schedule', // zeitplan
|
||||||
|
'trigger.formSubmit', // formular-absendung
|
||||||
|
'flow.ifElse',
|
||||||
|
'flow.switch',
|
||||||
|
'flow.merge',
|
||||||
|
'flow.loop',
|
||||||
|
'flow.wait',
|
||||||
|
'flow.stop', // alle abschnitt ablauf
|
||||||
|
'data.setFields',
|
||||||
|
'data.filter',
|
||||||
|
'data.parseJson',
|
||||||
|
'data.template', // alle abschnitt daten
|
||||||
|
'ai.webResearch',
|
||||||
|
'ai.summarizeDocument',
|
||||||
|
'ai.translateDocument',
|
||||||
|
'ai.convertDocument',
|
||||||
|
'ai.generateDocument',
|
||||||
|
'ai.generateCode', // alle KI ausser ai.prompt
|
||||||
|
'sharepoint.listFiles', // dateien auflisten
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Default category display order */
|
||||||
|
export const CATEGORY_ORDER = [
|
||||||
|
'trigger',
|
||||||
|
'input',
|
||||||
|
'flow',
|
||||||
|
'data',
|
||||||
|
'ai',
|
||||||
|
'email',
|
||||||
|
'sharepoint',
|
||||||
|
] as const;
|
||||||
78
src/components/Automation2FlowEditor/graphUtils.ts
Normal file
78
src/components/Automation2FlowEditor/graphUtils.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
/**
|
||||||
|
* Automation2 Flow Editor - Graph conversion utilities
|
||||||
|
* Converts between API graph format and canvas internal format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NodeType } from '../../api/automation2Api';
|
||||||
|
import type { CanvasNode, CanvasConnection } from './FlowCanvas';
|
||||||
|
import type { Automation2Graph } from '../../api/automation2Api';
|
||||||
|
|
||||||
|
export function fromApiGraph(
|
||||||
|
graph: Automation2Graph,
|
||||||
|
nodeTypes: NodeType[]
|
||||||
|
): { 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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toApiGraph(
|
||||||
|
nodes: CanvasNode[],
|
||||||
|
connections: CanvasConnection[]
|
||||||
|
): Automation2Graph {
|
||||||
|
const nodeMap = new Map(nodes.map((n) => [n.id, n]));
|
||||||
|
return {
|
||||||
|
nodes: nodes.map((n) => ({
|
||||||
|
id: n.id,
|
||||||
|
type: n.type,
|
||||||
|
x: n.x,
|
||||||
|
y: n.y,
|
||||||
|
title: n.title,
|
||||||
|
comment: n.comment,
|
||||||
|
parameters: n.parameters ?? {},
|
||||||
|
})),
|
||||||
|
connections: connections.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,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
9
src/components/Automation2FlowEditor/index.ts
Normal file
9
src/components/Automation2FlowEditor/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export { Automation2FlowEditor } from './Automation2FlowEditor';
|
||||||
|
export { FlowCanvas } from './FlowCanvas';
|
||||||
|
export { NodeConfigPanel } from './NodeConfigPanel';
|
||||||
|
export { NodeSidebar } from './NodeSidebar';
|
||||||
|
export { NodeListItem } from './NodeListItem';
|
||||||
|
export { CanvasHeader } from './CanvasHeader';
|
||||||
|
export * from './utils';
|
||||||
|
export * from './constants';
|
||||||
|
export * from './graphUtils';
|
||||||
25
src/components/Automation2FlowEditor/utils.ts
Normal file
25
src/components/Automation2FlowEditor/utils.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* Automation2 Flow Editor - Utility functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type React from 'react';
|
||||||
|
import { CATEGORY_ICONS, DEFAULT_CATEGORY_ICON } from './categoryIcons';
|
||||||
|
|
||||||
|
/** Resolve localized label from string or { de, en, fr } object */
|
||||||
|
export function getLabel(
|
||||||
|
text: string | Record<string, string> | undefined,
|
||||||
|
lang = 'de'
|
||||||
|
): string {
|
||||||
|
if (!text) return '';
|
||||||
|
if (typeof text === 'string') return text;
|
||||||
|
const rec = text as Record<string, string>;
|
||||||
|
return rec[lang] ?? rec.en ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get icon for a category */
|
||||||
|
export function getCategoryIcon(categoryId: string): React.ReactNode {
|
||||||
|
return CATEGORY_ICONS[categoryId] ?? DEFAULT_CATEGORY_ICON;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Function type for resolving localized labels */
|
||||||
|
export type GetLabelFn = (text: string | Record<string, string> | undefined, lang?: string) => string;
|
||||||
306
src/components/FolderTree/SharepointBrowseTree.tsx
Normal file
306
src/components/FolderTree/SharepointBrowseTree.tsx
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
/**
|
||||||
|
* SharepointBrowseTree – Lazy-loading tree for SharePoint browse.
|
||||||
|
* Same look & feel as FolderTree (chevron, FaFolder/FaFolderOpen, styling).
|
||||||
|
* Loads children on expand via onLoadChildren(path).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { FaFolder, FaFolderOpen, FaChevronRight, FaGlobe } from 'react-icons/fa';
|
||||||
|
import styles from './FolderTree.module.css';
|
||||||
|
|
||||||
|
export interface BrowseEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
isFolder: boolean;
|
||||||
|
size?: number;
|
||||||
|
mimeType?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SharepointBrowseTreeProps {
|
||||||
|
/** Root path (usually "/") - children loaded via onLoadChildren */
|
||||||
|
rootPath?: string;
|
||||||
|
/** Load children for a given path. Returns folders and files. */
|
||||||
|
onLoadChildren: (path: string) => Promise<BrowseEntry[]>;
|
||||||
|
/** Called when user selects a file path */
|
||||||
|
onSelectFile: (path: string) => void;
|
||||||
|
/** Called when user selects a folder path (e.g. for destination). If provided, folder rows are selectable. */
|
||||||
|
onSelectFolder?: (path: string) => void;
|
||||||
|
/** Currently selected path (for highlight) */
|
||||||
|
selectedPath?: string | null;
|
||||||
|
/** Optional: pre-seed root children (e.g. from initial load) */
|
||||||
|
initialChildren?: BrowseEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fileIcon(mime?: string): string {
|
||||||
|
if (!mime) return '\uD83D\uDCC4';
|
||||||
|
if (mime.startsWith('image/')) return '\uD83D\uDDBC\uFE0F';
|
||||||
|
if (mime.includes('pdf')) return '\uD83D\uDCD5';
|
||||||
|
if (mime.includes('word') || mime.includes('docx')) return '\uD83D\uDCD8';
|
||||||
|
if (mime.includes('sheet') || mime.includes('xlsx') || mime.includes('csv')) return '\uD83D\uDCCA';
|
||||||
|
if (mime.includes('presentation') || mime.includes('pptx')) return '\uD83D\uDCD9';
|
||||||
|
if (mime.includes('zip') || mime.includes('tar') || mime.includes('gz')) return '\uD83D\uDCE6';
|
||||||
|
if (mime.startsWith('text/') || mime.includes('json') || mime.includes('xml')) return '\uD83D\uDCDD';
|
||||||
|
return '\uD83D\uDCC4';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── File row ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function _FileRow({
|
||||||
|
entry,
|
||||||
|
selectedPath,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
entry: BrowseEntry;
|
||||||
|
selectedPath: string | null | undefined;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const isSelected = selectedPath === entry.path;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.treeNode} ${styles.fileNode} ${isSelected ? styles.selected : ''}`}
|
||||||
|
onClick={() => onSelect(entry.path)}
|
||||||
|
title={entry.path}
|
||||||
|
>
|
||||||
|
<span className={styles.chevron + ' ' + styles.empty} />
|
||||||
|
<span className={styles.fileIcon}>{_fileIcon(entry.mimeType)}</span>
|
||||||
|
<span className={styles.folderName}>{entry.name}</span>
|
||||||
|
{entry.size != null && (
|
||||||
|
<span className={styles.fileSize}>
|
||||||
|
{(entry.size / 1024).toFixed(0)}K
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Folder row (expandable, lazy-loads children) ───────────────────────── */
|
||||||
|
|
||||||
|
function _FolderRow({
|
||||||
|
entry,
|
||||||
|
selectedPath,
|
||||||
|
expandedPaths,
|
||||||
|
loadedChildren,
|
||||||
|
loadingPaths,
|
||||||
|
onToggle,
|
||||||
|
onSelectFile,
|
||||||
|
onSelectFolder,
|
||||||
|
}: {
|
||||||
|
entry: BrowseEntry;
|
||||||
|
selectedPath: string | null | undefined;
|
||||||
|
expandedPaths: Set<string>;
|
||||||
|
loadedChildren: Record<string, BrowseEntry[]>;
|
||||||
|
loadingPaths: Set<string>;
|
||||||
|
onToggle: (path: string) => void;
|
||||||
|
onSelectFile: (path: string) => void;
|
||||||
|
onSelectFolder?: (path: string) => void;
|
||||||
|
}) {
|
||||||
|
const isExpanded = expandedPaths.has(entry.path);
|
||||||
|
const isSelected = selectedPath === entry.path;
|
||||||
|
const children = loadedChildren[entry.path] ?? [];
|
||||||
|
const folders = children.filter((c) => c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const files = children.filter((c) => !c.isFolder).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const isLoading = isExpanded && loadingPaths.has(entry.path);
|
||||||
|
|
||||||
|
const handleRowClick = (e: React.MouseEvent) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
if (target.closest(`.${styles.chevron}`)) return;
|
||||||
|
if (onSelectFolder) {
|
||||||
|
onSelectFolder(entry.path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onToggle(entry.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChevronClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onToggle(entry.path);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={`${styles.treeNode} ${onSelectFolder && isSelected ? styles.selected : ''}`}
|
||||||
|
onClick={handleRowClick}
|
||||||
|
title={entry.path}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${styles.chevron} ${isExpanded ? styles.expanded : ''}`}
|
||||||
|
onClick={handleChevronClick}
|
||||||
|
title={isExpanded ? 'Einklappen' : 'Erweitern'}
|
||||||
|
>
|
||||||
|
<FaChevronRight />
|
||||||
|
</span>
|
||||||
|
<span className={styles.folderIcon}>
|
||||||
|
{isExpanded ? <FaFolderOpen /> : <FaFolder />}
|
||||||
|
</span>
|
||||||
|
<span className={styles.folderName}>{entry.name}</span>
|
||||||
|
{isLoading && (
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}>…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className={styles.children}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
|
||||||
|
Wird geladen…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{folders.map((child) => (
|
||||||
|
<_FolderRow
|
||||||
|
key={child.path}
|
||||||
|
entry={child}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
expandedPaths={expandedPaths}
|
||||||
|
loadedChildren={loadedChildren}
|
||||||
|
loadingPaths={loadingPaths}
|
||||||
|
onToggle={onToggle}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
onSelectFolder={onSelectFolder}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{files.map((child) => (
|
||||||
|
<_FileRow
|
||||||
|
key={child.path}
|
||||||
|
entry={child}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={onSelectFile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{children.length === 0 && (
|
||||||
|
<div style={{ padding: '0.4rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
|
||||||
|
Leer
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Root component ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function SharepointBrowseTree({
|
||||||
|
rootPath = '/',
|
||||||
|
onLoadChildren,
|
||||||
|
onSelectFile,
|
||||||
|
onSelectFolder,
|
||||||
|
selectedPath,
|
||||||
|
initialChildren = [],
|
||||||
|
}: SharepointBrowseTreeProps) {
|
||||||
|
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set([rootPath]));
|
||||||
|
const [loadedChildren, setLoadedChildren] = useState<Record<string, BrowseEntry[]>>(() =>
|
||||||
|
initialChildren.length > 0 ? { [rootPath]: initialChildren } : {}
|
||||||
|
);
|
||||||
|
const [loadingPaths, setLoadingPaths] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const loadPath = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
setLoadingPaths((p) => new Set(p).add(path));
|
||||||
|
try {
|
||||||
|
const items = await onLoadChildren(path);
|
||||||
|
setLoadedChildren((prev) => ({ ...prev, [path]: items }));
|
||||||
|
} catch {
|
||||||
|
setLoadedChildren((prev) => ({ ...prev, [path]: [] }));
|
||||||
|
} finally {
|
||||||
|
setLoadingPaths((p) => {
|
||||||
|
const next = new Set(p);
|
||||||
|
next.delete(path);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onLoadChildren]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(
|
||||||
|
(path: string) => {
|
||||||
|
setExpandedPaths((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(path)) {
|
||||||
|
next.delete(path);
|
||||||
|
} else {
|
||||||
|
next.add(path);
|
||||||
|
loadPath(path);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[loadPath]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (rootPath in loadedChildren) return;
|
||||||
|
if (initialChildren.length > 0) return;
|
||||||
|
loadPath(rootPath);
|
||||||
|
}, [rootPath, initialChildren.length, loadPath]);
|
||||||
|
|
||||||
|
const rootItems = loadedChildren[rootPath] ?? [];
|
||||||
|
const rootLoading = loadingPaths.has(rootPath);
|
||||||
|
const rootFolders = rootItems.filter((e) => e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const rootFiles = rootItems.filter((e) => !e.isFolder).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
const isRootExpanded = expandedPaths.has(rootPath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.folderTree}>
|
||||||
|
<div
|
||||||
|
className={`${styles.treeNode} ${selectedPath === null || selectedPath === undefined ? styles.selected : ''}`}
|
||||||
|
style={{ fontWeight: 600 }}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${styles.chevron} ${isRootExpanded ? styles.expanded : ''}`}
|
||||||
|
onClick={() => handleToggle(rootPath)}
|
||||||
|
>
|
||||||
|
<FaChevronRight />
|
||||||
|
</span>
|
||||||
|
<span className={styles.folderIcon}><FaGlobe /></span>
|
||||||
|
<span className={`${styles.folderName} ${styles.rootLabel}`}>SharePoint</span>
|
||||||
|
{rootLoading && (
|
||||||
|
<span style={{ fontSize: 10, color: 'var(--color-text-secondary,#999)', marginLeft: 4 }}>…</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isRootExpanded && (
|
||||||
|
<div className={styles.children}>
|
||||||
|
{rootLoading ? (
|
||||||
|
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#666)' }}>
|
||||||
|
Sites werden geladen…
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{rootFolders.map((entry) => (
|
||||||
|
<_FolderRow
|
||||||
|
key={entry.path}
|
||||||
|
entry={entry}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
expandedPaths={expandedPaths}
|
||||||
|
loadedChildren={loadedChildren}
|
||||||
|
loadingPaths={loadingPaths}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
onSelectFile={onSelectFile}
|
||||||
|
onSelectFolder={onSelectFolder}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{rootFiles.map((entry) => (
|
||||||
|
<_FileRow
|
||||||
|
key={entry.path}
|
||||||
|
entry={entry}
|
||||||
|
selectedPath={selectedPath}
|
||||||
|
onSelect={onSelectFile}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{rootItems.length === 0 && !rootLoading && (
|
||||||
|
<div style={{ padding: '0.5rem 1rem', fontSize: '0.8rem', color: 'var(--color-text-secondary,#999)' }}>
|
||||||
|
Keine Einträge
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -115,6 +115,10 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'feature.realestate': <FaBuilding />,
|
'feature.realestate': <FaBuilding />,
|
||||||
'feature.chatworkflow': <FaPlay />,
|
'feature.chatworkflow': <FaPlay />,
|
||||||
'feature.automation': <FaCogs />,
|
'feature.automation': <FaCogs />,
|
||||||
|
'feature.automation2': <FaProjectDiagram />,
|
||||||
|
'page.feature.automation2.editor': <FaProjectDiagram />,
|
||||||
|
'page.feature.automation2.workflows': <FaProjectDiagram />,
|
||||||
|
'page.feature.automation2.workflows-tasks': <FaClipboardList />,
|
||||||
'page.feature.chatbot.conversations': <FaComments />,
|
'page.feature.chatbot.conversations': <FaComments />,
|
||||||
'feature.chatbot': <FaComments />,
|
'feature.chatbot': <FaComments />,
|
||||||
'feature.teamsbot': <FaHeadset />,
|
'feature.teamsbot': <FaHeadset />,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Navigate, useParams } from 'react-router-dom';
|
||||||
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||||
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
@ -30,6 +31,11 @@ import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/r
|
||||||
// Automation Views
|
// Automation Views
|
||||||
import { AutomationDefinitionsView, AutomationTemplatesView } from './views/automation';
|
import { AutomationDefinitionsView, AutomationTemplatesView } from './views/automation';
|
||||||
|
|
||||||
|
// Automation2 Views
|
||||||
|
import { Automation2Page } from './views/automation2/Automation2Page';
|
||||||
|
import { Automation2WorkflowsPage } from './views/automation2/Automation2WorkflowsPage';
|
||||||
|
import { Automation2WorkflowsTasksPage } from './views/automation2/Automation2WorkflowsTasksPage';
|
||||||
|
|
||||||
// Workspace Views
|
// Workspace Views
|
||||||
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
||||||
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
|
import { WorkspaceEditorPage } from './views/workspace/WorkspaceEditorPage';
|
||||||
|
|
@ -128,6 +134,11 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
definitions: AutomationDefinitionsView,
|
definitions: AutomationDefinitionsView,
|
||||||
templates: AutomationTemplatesView,
|
templates: AutomationTemplatesView,
|
||||||
},
|
},
|
||||||
|
automation2: {
|
||||||
|
editor: Automation2Page,
|
||||||
|
workflows: Automation2WorkflowsPage,
|
||||||
|
'workflows-tasks': Automation2WorkflowsTasksPage,
|
||||||
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
dashboard: WorkspacePage,
|
dashboard: WorkspacePage,
|
||||||
editor: WorkspaceEditorPage,
|
editor: WorkspaceEditorPage,
|
||||||
|
|
@ -162,6 +173,12 @@ interface FeatureViewPageProps {
|
||||||
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
const { instance, featureCode, isValid } = useCurrentInstance();
|
const { instance, featureCode, isValid } = useCurrentInstance();
|
||||||
const { currentLanguage } = useLanguage();
|
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
|
// Berechtigungs-Check
|
||||||
const viewCode = `${featureCode}-${view}`;
|
const viewCode = `${featureCode}-${view}`;
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
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 { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useStore } from '../hooks/useStore';
|
import { useStore } from '../hooks/useStore';
|
||||||
import type { StoreFeature } from '../api/storeApi';
|
import type { StoreFeature } from '../api/storeApi';
|
||||||
|
|
@ -15,6 +15,7 @@ import styles from './Store.module.css';
|
||||||
|
|
||||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||||
automation: <FaCogs />,
|
automation: <FaCogs />,
|
||||||
|
automation2: <FaProjectDiagram />,
|
||||||
teamsbot: <FaHeadset />,
|
teamsbot: <FaHeadset />,
|
||||||
workspace: <FaComments />,
|
workspace: <FaComments />,
|
||||||
commcoach: <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.',
|
en: 'Create and manage automations to handle recurring tasks efficiently.',
|
||||||
fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.',
|
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: {
|
teamsbot: {
|
||||||
de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.',
|
||||||
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
|
en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.',
|
||||||
|
|
|
||||||
38
src/pages/views/automation2/Automation2Page.tsx
Normal file
38
src/pages/views/automation2/Automation2Page.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
/**
|
||||||
|
* Automation2Page
|
||||||
|
*
|
||||||
|
* n8n-style flow builder with backend-driven node list.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
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 [searchParams] = useSearchParams();
|
||||||
|
const workflowId = searchParams.get('workflowId');
|
||||||
|
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}
|
||||||
|
initialWorkflowId={workflowId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
233
src/pages/views/automation2/Automation2WorkflowsPage.tsx
Normal file
233
src/pages/views/automation2/Automation2WorkflowsPage.tsx
Normal file
|
|
@ -0,0 +1,233 @@
|
||||||
|
/**
|
||||||
|
* Automation2WorkflowsPage
|
||||||
|
* List of saved workflows with FormGeneratorTable.
|
||||||
|
* Shows: label, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
|
||||||
|
* Actions: Edit (navigate to editor), Delete, Execute.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { FaPlay, FaSync } from 'react-icons/fa';
|
||||||
|
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
|
||||||
|
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||||
|
import { useApiRequest } from '../../../hooks/useApi';
|
||||||
|
import {
|
||||||
|
fetchWorkflows,
|
||||||
|
deleteWorkflow,
|
||||||
|
executeGraph,
|
||||||
|
type Automation2Workflow,
|
||||||
|
} from '../../../api/automation2Api';
|
||||||
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
|
import { formatUnixTimestamp } from '../../../utils/time';
|
||||||
|
import styles from '../../../pages/admin/Admin.module.css';
|
||||||
|
|
||||||
|
function formatTs(ts?: number): string {
|
||||||
|
if (ts == null || ts <= 0) return '—';
|
||||||
|
const sec = ts < 1e12 ? ts : ts / 1000;
|
||||||
|
const { time } = formatUnixTimestamp(sec, undefined, {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Automation2WorkflowsPage: React.FC = () => {
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
const { mandateId } = useParams<{ mandateId: string }>();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [executingId, setExecutingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const list = await fetchWorkflows(request, instanceId);
|
||||||
|
setWorkflows(list);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Automation2] load workflows failed', e);
|
||||||
|
showError('Fehler beim Laden der Workflows');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, request, showError]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleDelete = useCallback(
|
||||||
|
async (workflowId: string): Promise<boolean> => {
|
||||||
|
if (!instanceId) return false;
|
||||||
|
try {
|
||||||
|
await deleteWorkflow(request, instanceId, workflowId);
|
||||||
|
showSuccess('Workflow gelöscht');
|
||||||
|
await load();
|
||||||
|
return true;
|
||||||
|
} catch (e: any) {
|
||||||
|
showError(`Fehler: ${e?.message || 'Löschen fehlgeschlagen'}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[instanceId, request, showSuccess, showError, load]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(row: Automation2Workflow) => {
|
||||||
|
if (!mandateId || !instanceId) return;
|
||||||
|
navigate(`/mandates/${mandateId}/automation2/${instanceId}/editor?workflowId=${row.id}`);
|
||||||
|
},
|
||||||
|
[mandateId, instanceId, navigate]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleExecute = useCallback(
|
||||||
|
async (row: Automation2Workflow) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setExecutingId(row.id);
|
||||||
|
try {
|
||||||
|
const result = await executeGraph(request, instanceId, row.graph!, row.id);
|
||||||
|
if (result?.success) {
|
||||||
|
if (result?.paused) {
|
||||||
|
showSuccess('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.');
|
||||||
|
} else {
|
||||||
|
showSuccess('Workflow ausgeführt');
|
||||||
|
}
|
||||||
|
await load();
|
||||||
|
} else {
|
||||||
|
showError(result?.error || 'Ausführung fehlgeschlagen');
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
showError(`Fehler: ${e?.message || 'Ausführung fehlgeschlagen'}`);
|
||||||
|
} finally {
|
||||||
|
setExecutingId(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[instanceId, request, showSuccess, showError, load]
|
||||||
|
);
|
||||||
|
|
||||||
|
const columns: ColumnConfig[] = [
|
||||||
|
{ key: 'label', label: 'Workflow', type: 'string', width: 200, sortable: true },
|
||||||
|
{
|
||||||
|
key: 'isRunning',
|
||||||
|
label: 'Läuft',
|
||||||
|
type: 'boolean',
|
||||||
|
width: 80,
|
||||||
|
formatter: (value: boolean) =>
|
||||||
|
value ? (
|
||||||
|
<span style={{ color: 'var(--success-color, #28a745)', fontWeight: 600 }}>✓ Ja</span>
|
||||||
|
) : (
|
||||||
|
<span style={{ color: 'var(--text-secondary, #666)' }}>Nein</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'stuckAtNodeLabel',
|
||||||
|
label: 'Steht bei',
|
||||||
|
type: 'string',
|
||||||
|
width: 160,
|
||||||
|
formatter: (value: string, row: Automation2Workflow) =>
|
||||||
|
row.isRunning && (value || row.stuckAtNodeId)
|
||||||
|
? value || row.stuckAtNodeId || '—'
|
||||||
|
: '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'createdAt',
|
||||||
|
label: 'Erstellt',
|
||||||
|
type: 'number',
|
||||||
|
width: 140,
|
||||||
|
formatter: (v: number) => formatTs(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'lastStartedAt',
|
||||||
|
label: 'Zuletzt gestartet',
|
||||||
|
type: 'number',
|
||||||
|
width: 160,
|
||||||
|
formatter: (v: number) => formatTs(v),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'runCount',
|
||||||
|
label: 'Läufe',
|
||||||
|
type: 'number',
|
||||||
|
width: 80,
|
||||||
|
formatter: (v: number) => (v != null ? String(v) : '0'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const hookData = {
|
||||||
|
refetch: load,
|
||||||
|
handleDelete: (id: string) => handleDelete(id),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!instanceId) {
|
||||||
|
return (
|
||||||
|
<div className={styles.adminPage}>
|
||||||
|
<p>Keine Feature-Instanz gefunden.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<h1 className={styles.pageTitle}>Gespeicherte Workflows</h1>
|
||||||
|
<p className={styles.pageSubtitle}>
|
||||||
|
Workflows verwalten, ausführen und bearbeiten
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => load()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
|
<FormGeneratorTable<Automation2Workflow>
|
||||||
|
data={workflows}
|
||||||
|
columns={columns}
|
||||||
|
loading={loading}
|
||||||
|
pagination={true}
|
||||||
|
pageSize={25}
|
||||||
|
searchable={true}
|
||||||
|
filterable={true}
|
||||||
|
sortable={true}
|
||||||
|
selectable={false}
|
||||||
|
actionButtons={[
|
||||||
|
{
|
||||||
|
type: 'edit',
|
||||||
|
title: 'Bearbeiten',
|
||||||
|
onAction: handleEdit,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'delete',
|
||||||
|
title: 'Löschen',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
customActions={[
|
||||||
|
{
|
||||||
|
id: 'execute',
|
||||||
|
icon: <FaPlay />,
|
||||||
|
title: 'Ausführen',
|
||||||
|
onClick: (row) => handleExecute(row),
|
||||||
|
loading: (row) => executingId === row.id,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onDelete={(row) => handleDelete(row.id)}
|
||||||
|
hookData={hookData}
|
||||||
|
emptyMessage="Keine Workflows gefunden. Erstelle einen im Editor."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
281
src/pages/views/automation2/Automation2WorkflowsTasks.module.css
Normal file
281
src/pages/views/automation2/Automation2WorkflowsTasks.module.css
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
.container {
|
||||||
|
padding: 1.5rem;
|
||||||
|
max-width: 900px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container h2 {
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 0.75rem 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.completedHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 0;
|
||||||
|
text-align: left;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.completedHeader:hover {
|
||||||
|
color: var(--primary-color, #007bff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.completedList {
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskMeta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 0.5rem 1.25rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskMetaRow {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaLabel {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaValue {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
.metaValueMono {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: monospace;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 3rem;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflowList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflowItem {
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflowHeader {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--bg-secondary, #f8f9fa);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workflowHeader:hover {
|
||||||
|
background: var(--bg-hover, #e9ecef);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
margin-left: auto;
|
||||||
|
background: var(--primary-color, #007bff);
|
||||||
|
color: white;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskList {
|
||||||
|
padding: 1rem;
|
||||||
|
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--text-tertiary, #999);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.taskCard {
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border: 1px solid var(--border-color, #e0e0e0);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
461
src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
Normal file
461
src/pages/views/automation2/Automation2WorkflowsTasksPage.tsx
Normal file
|
|
@ -0,0 +1,461 @@
|
||||||
|
/**
|
||||||
|
* Automation2WorkflowsTasksPage
|
||||||
|
* Tasks only (no workflow grouping).
|
||||||
|
* Open tasks at top, completed tasks at bottom (expandable, scrollable).
|
||||||
|
* Each task shows workflow, created, due, step, type, and action.
|
||||||
|
*/
|
||||||
|
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 {
|
||||||
|
fetchTasks,
|
||||||
|
completeTask,
|
||||||
|
type Automation2Task,
|
||||||
|
} from '../../../api/automation2Api';
|
||||||
|
import { Popup } from '../../../components/UiComponents/Popup';
|
||||||
|
import styles from './Automation2WorkflowsTasks.module.css';
|
||||||
|
|
||||||
|
const NODE_TYPE_LABELS: 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatTimestamp(ts?: number): string {
|
||||||
|
if (ts == null || ts <= 0) return '—';
|
||||||
|
const d = new Date(typeof ts === 'number' && ts < 1e12 ? ts * 1000 : ts);
|
||||||
|
return d.toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNodeStepLabel(config: Record<string, unknown>): string {
|
||||||
|
const title = config?.title;
|
||||||
|
if (typeof title === 'string' && title.trim()) return title;
|
||||||
|
const label = config?.label;
|
||||||
|
if (typeof label === 'string' && label.trim()) return label;
|
||||||
|
if (typeof label === 'object' && label != null && 'de' in (label as Record<string, string>)) {
|
||||||
|
return (label as Record<string, string>).de ?? (label as Record<string, string>).en ?? '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Automation2WorkflowsTasksPage: React.FC = () => {
|
||||||
|
const instanceId = useInstanceId();
|
||||||
|
const { request } = useApiRequest();
|
||||||
|
const [tasks, setTasks] = useState<Automation2Task[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [completedExpanded, setCompletedExpanded] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const taskList = await fetchTasks(request, instanceId);
|
||||||
|
setTasks(taskList);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Automation2] load failed', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [instanceId, request]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const handleComplete = async (taskId: string, result: Record<string, unknown>) => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
setSubmitting(taskId);
|
||||||
|
try {
|
||||||
|
await completeTask(request, instanceId, taskId, result);
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[Automation2] complete failed', e);
|
||||||
|
} finally {
|
||||||
|
setSubmitting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTasks = tasks.filter((t) => t.status === 'pending');
|
||||||
|
const completedTasks = tasks.filter((t) => t.status !== 'pending');
|
||||||
|
|
||||||
|
if (!instanceId) {
|
||||||
|
return (
|
||||||
|
<div className={styles.placeholder}>
|
||||||
|
<h2>Tasks</h2>
|
||||||
|
<p>Keine Feature-Instanz gefunden.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className={styles.loading}>
|
||||||
|
<FaSpinner className={styles.spinner} />
|
||||||
|
<p>Lade Tasks…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.container}>
|
||||||
|
<h2>Tasks</h2>
|
||||||
|
|
||||||
|
{/* Open tasks */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>
|
||||||
|
Offene Tasks
|
||||||
|
{openTasks.length > 0 && <span className={styles.badge}>{openTasks.length}</span>}
|
||||||
|
</h3>
|
||||||
|
{openTasks.length === 0 ? (
|
||||||
|
<p className={styles.empty}>Keine offenen Tasks</p>
|
||||||
|
) : (
|
||||||
|
<div className={styles.taskList}>
|
||||||
|
{openTasks.map((task) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onSubmit={(result) => handleComplete(task.id, result)}
|
||||||
|
submitting={submitting === task.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Completed tasks */}
|
||||||
|
<section className={styles.section}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.completedHeader}
|
||||||
|
onClick={() => setCompletedExpanded((p) => !p)}
|
||||||
|
>
|
||||||
|
{completedExpanded ? <FaChevronDown /> : <FaChevronRight />}
|
||||||
|
<span>Erledigte Tasks</span>
|
||||||
|
{completedTasks.length > 0 && (
|
||||||
|
<span className={styles.badge}>{completedTasks.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{completedExpanded && (
|
||||||
|
<div className={styles.completedList}>
|
||||||
|
{completedTasks.length === 0 ? (
|
||||||
|
<p className={styles.empty}>Keine erledigten Tasks</p>
|
||||||
|
) : (
|
||||||
|
completedTasks.map((task) => (
|
||||||
|
<TaskCard
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
onSubmit={(result) => handleComplete(task.id, result)}
|
||||||
|
submitting={submitting === task.id}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface TaskCardProps {
|
||||||
|
task: Automation2Task;
|
||||||
|
onSubmit: (result: Record<string, unknown>) => void;
|
||||||
|
submitting: boolean;
|
||||||
|
readOnly?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskCard: React.FC<TaskCardProps> = ({
|
||||||
|
task,
|
||||||
|
onSubmit,
|
||||||
|
submitting,
|
||||||
|
readOnly = false,
|
||||||
|
}) => {
|
||||||
|
const [formData, setFormData] = useState<Record<string, unknown>>({});
|
||||||
|
const [formPopupOpen, setFormPopupOpen] = useState(false);
|
||||||
|
const config = task.config ?? {};
|
||||||
|
const nodeType = task.nodeType;
|
||||||
|
const stepLabel = getNodeStepLabel(config);
|
||||||
|
|
||||||
|
const renderInput = () => {
|
||||||
|
if (readOnly) return null;
|
||||||
|
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>
|
||||||
|
{config.title != null && String(config.title) !== '' && <h4>{String(config.title)}</h4>}
|
||||||
|
{config.description != null && String(config.description) !== '' && <p>{String(config.description)}</p>}
|
||||||
|
<div className={styles.approvalButtons}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSubmit({ approved: true })}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
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}
|
||||||
|
>
|
||||||
|
{typeof config.confirmLabel === 'string' ? config.confirmLabel : 'Bestätigen'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSubmit({ confirmed: false })}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
{typeof config.rejectLabel === 'string' ? 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.taskCard}>
|
||||||
|
<div className={styles.taskMeta}>
|
||||||
|
<div className={styles.taskMetaRow}>
|
||||||
|
<span className={styles.metaLabel}>Workflow</span>
|
||||||
|
<span className={styles.metaValue}>
|
||||||
|
{task.workflowLabel || task.workflowId || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.taskMetaRow}>
|
||||||
|
<span className={styles.metaLabel}>Erstellt</span>
|
||||||
|
<span className={styles.metaValue}>
|
||||||
|
{formatTimestamp(task.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={styles.taskMetaRow}>
|
||||||
|
<span className={styles.metaLabel}>Fällig</span>
|
||||||
|
<span className={styles.metaValue}>
|
||||||
|
{formatTimestamp(task.dueAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{stepLabel && (
|
||||||
|
<div className={styles.taskMetaRow}>
|
||||||
|
<span className={styles.metaLabel}>Schritt</span>
|
||||||
|
<span className={styles.metaValue}>{stepLabel}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={styles.taskMetaRow}>
|
||||||
|
<span className={styles.metaLabel}>Typ</span>
|
||||||
|
<span className={styles.metaValue}>
|
||||||
|
{NODE_TYPE_LABELS[nodeType] ?? nodeType}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{task.nodeId && (
|
||||||
|
<div className={styles.taskMetaRow}>
|
||||||
|
<span className={styles.metaLabel}>Node</span>
|
||||||
|
<span className={styles.metaValueMono}>{task.nodeId}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{renderInput()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -261,6 +261,16 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
{ code: 'logs', label: { de: 'Protokolle', en: 'Logs' }, path: 'logs' },
|
{ 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', label: { de: 'Workflows', en: 'Workflows' }, path: 'workflows' },
|
||||||
|
{ code: 'workflows-tasks', label: { de: 'Tasks', en: 'Tasks' }, path: 'workflows-tasks' },
|
||||||
|
]
|
||||||
|
},
|
||||||
neutralization: {
|
neutralization: {
|
||||||
code: 'neutralization',
|
code: 'neutralization',
|
||||||
label: { de: 'Neutralisierung', en: 'Neutralization', fr: 'Neutralisation' },
|
label: { de: 'Neutralisierung', en: 'Neutralization', fr: 'Neutralisation' },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue