automation unification implemented

This commit is contained in:
ValueOn AG 2026-04-07 00:49:12 +02:00
parent b0c5b534ff
commit 3bf79e1ae5
101 changed files with 2321 additions and 6297 deletions

View file

@ -38,7 +38,7 @@ import { SettingsPage } from './pages/Settings';
import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminAutomationLogsPage, AdminLogsPage } from './pages/admin';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
@ -195,8 +195,6 @@ function App() {
<Route path="mandates" element={<BillingMandateView />} />
</Route>
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
<Route path="automation-logs" element={<AdminAutomationLogsPage />} />
<Route path="logs" element={<AdminLogsPage />} />
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />

View file

@ -1,532 +0,0 @@
/**
* 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;
/** Labels per output (e.g. ["Ja", "Nein"] for flow.ifElse) */
outputLabels?: string[];
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;
}
/** Entry point / start configured outside the canvas (manual, form, schedule, …) */
export interface WorkflowEntryPoint {
id: string;
kind: string;
category: 'on_demand' | 'always_on';
enabled: boolean;
title: Record<string, string> | string;
description?: Record<string, string>;
config: Record<string, unknown>;
}
export interface Automation2Workflow {
id: string;
label: string;
graph: Automation2Graph;
active?: boolean;
/** Entry points (Starts) — how this workflow may be invoked */
invocations?: WorkflowEntryPoint[];
/** 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 interface ExecuteGraphOptions {
/** Use a configured start on the saved workflow */
entryPointId?: string;
/** Full run envelope (overrides entry point mapping) */
runEnvelope?: Record<string, unknown>;
/** Merged into envelope.payload */
payload?: Record<string, unknown>;
}
export async function executeGraph(
request: ApiRequestFunction,
instanceId: string,
graph: Automation2Graph,
workflowId?: string,
options?: ExecuteGraphOptions
): 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, options }
);
const start = performance.now();
try {
const data: Record<string, unknown> = { graph, workflowId };
if (options?.entryPointId) data.entryPointId = options.entryPointId;
if (options?.runEnvelope) data.runEnvelope = options.runEnvelope;
if (options?.payload && Object.keys(options.payload).length > 0) data.payload = options.payload;
const result = await request({
url: `/api/automation2/${instanceId}/execute`,
method: 'post',
data,
});
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,
params?: { active?: boolean }
): Promise<Automation2Workflow[]> {
const data = await request({
url: `/api/automation2/${instanceId}/workflows`,
method: 'get',
params: params?.active !== undefined ? { active: params.active } : undefined,
});
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; invocations?: WorkflowEntryPoint[] }
): 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;
invocations?: WorkflowEntryPoint[];
active?: boolean;
}
): 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 ?? [];
}
export interface CompletedRun extends Automation2Run {
workflowLabel?: string;
sysModifiedAt?: number;
sysCreatedAt?: number;
}
export async function fetchCompletedRuns(
request: ApiRequestFunction,
instanceId: string,
limit = 20
): Promise<CompletedRun[]> {
const data = await request({
url: `/api/automation2/${instanceId}/runs/completed`,
method: 'get',
params: { limit },
});
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 sysCreatedAt) */
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 };
}
/** ClickUp GET /task/{taskId} — list.id for resolving list-scoped fields when only task id is known. */
export async function fetchClickupTask(
request: ApiRequestFunction,
connectionId: string,
taskId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/tasks/${encodeURIComponent(taskId)}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp list metadata (statuses, etc.) — GET /api/clickup/{connectionId}/lists/{listId}. */
export async function fetchClickupList(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/lists/${listId}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp workspace/team (members for assignees) — GET /api/clickup/{connectionId}/teams/{teamId}. */
export async function fetchClickupTeam(
request: ApiRequestFunction,
connectionId: string,
teamId: string
): Promise<Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/teams/${teamId}`,
method: 'get',
});
return data && typeof data === 'object' ? (data as Record<string, unknown>) : {};
}
/** ClickUp list custom fields (GET /api/clickup/{connectionId}/lists/{listId}/fields). */
export async function fetchClickupListFields(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<{ fields?: unknown[] } & Record<string, unknown>> {
const data = await request({
url: `/api/clickup/${connectionId}/lists/${listId}/fields`,
method: 'get',
});
return (data && typeof data === 'object' ? data : {}) as { fields?: unknown[] } & Record<string, unknown>;
}
/** ClickUp GET /list/{id}/task page (tasks in a list for relationship dropdowns). */
export interface ClickupListTaskItem {
id?: string;
name?: string;
}
export async function fetchClickupListTasks(
request: ApiRequestFunction,
connectionId: string,
listId: string,
options?: { page?: number; includeClosed?: boolean }
): Promise<
{ tasks?: ClickupListTaskItem[]; last_page?: boolean } & Record<string, unknown>
> {
const data = await request({
url: `/api/clickup/${connectionId}/lists/${listId}/tasks`,
method: 'get',
params: {
page: options?.page ?? 0,
include_closed: options?.includeClosed ?? false,
},
});
return (data && typeof data === 'object' ? data : {}) as {
tasks?: ClickupListTaskItem[];
last_page?: boolean;
} & Record<string, unknown>;
}
/** Paginated tasks in a list — for ClickUp relationship dropdowns and input.form „ClickUp-Aufgabe“. */
export async function loadClickupListTasksForDropdown(
request: ApiRequestFunction,
connectionId: string,
listId: string
): Promise<Array<{ id: string; name: string }>> {
const acc: Array<{ id: string; name: string }> = [];
const seen = new Set<string>();
const maxPages = 12;
const pageSizeHint = 100;
for (let page = 0; page < maxPages; page++) {
const data = await fetchClickupListTasks(request, connectionId, listId, {
page,
includeClosed: false,
});
if (data && typeof data === 'object' && 'error' in data && (data as { error?: unknown }).error) {
const err = (data as { error?: unknown }).error;
const body = (data as { body?: string }).body;
throw new Error(
typeof err === 'string' ? err + (body ? `: ${body.slice(0, 200)}` : '') : 'ClickUp API error'
);
}
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
for (const t of tasks) {
const id = t?.id != null ? String(t.id) : '';
if (!id || seen.has(id)) continue;
seen.add(id);
acc.push({ id, name: String(t.name ?? id) });
}
const rawLast = (data as Record<string, unknown>).last_page;
const last =
rawLast === true ||
rawLast === 'true' ||
tasks.length === 0 ||
tasks.length < pageSizeHint;
if (last) break;
}
acc.sort((a, b) => a.name.localeCompare(b.name, 'de'));
return acc;
}

View file

@ -1,385 +0,0 @@
import { ApiRequestOptions } from '../hooks/useApi';
// ============================================================================
// TYPES & INTERFACES
// ============================================================================
export interface Automation {
id: string;
mandateId: string;
featureInstanceId: string;
label: string;
template: string | object;
placeholders: Record<string, string>;
schedule: string;
active: boolean;
status?: string;
lastExecution?: number;
nextExecution?: number;
executionLogs?: AutomationLog[];
allowedProviders?: string[];
sysCreatedAt?: number;
_updatedAt?: number;
sysCreatedByUserName?: string;
mandateName?: string;
featureInstanceName?: string;
[key: string]: any;
}
export interface AutomationLog {
id: string;
timestamp: number;
status: string;
workflowId?: string;
messages?: string[];
}
// Multilingual text type (matches backend TextMultilingual)
export interface TextMultilingual {
en: string;
ge?: string;
fr?: string;
it?: string;
}
// AutomationTemplate from DB
export interface AutomationTemplate {
id: string;
label: TextMultilingual;
overview?: TextMultilingual;
template: string; // JSON string with {{KEY:...}} placeholders
sysCreatedAt?: number;
sysCreatedBy?: string;
sysCreatedByUserName?: string;
}
// Workflow action definition from backend
export interface WorkflowAction {
method: string;
action: string;
actionId: string;
description: string;
category?: string;
parameters: WorkflowActionParameter[];
exampleJson: {
execMethod: string;
execAction: string;
execParameters: Record<string, any>;
execResultLabel: string;
};
}
export interface WorkflowActionParameter {
name: string;
type: string;
frontendType: string;
required: boolean;
default?: any;
description: string;
frontendOptions?: string | string[];
}
export interface CreateAutomationRequest {
label: string;
template: string;
placeholders?: Record<string, string>;
schedule?: string;
active?: boolean;
mandateId?: string;
featureInstanceId?: string;
}
export interface UpdateAutomationRequest {
label?: string;
template?: string;
placeholders?: Record<string, string>;
schedule?: string;
active?: boolean;
}
export interface ExecuteAutomationResponse {
id: string;
status: string;
workflowId?: string;
[key: string]: any;
}
// Type for the request function passed to API functions
export type ApiRequestFunction = (options: ApiRequestOptions<any>) => Promise<any>;
// ============================================================================
// API REQUEST FUNCTIONS
// ============================================================================
/**
* Fetch all automations for the current mandate
* Endpoint: GET /api/automations
*/
export async function fetchAutomations(request: ApiRequestFunction): Promise<Automation[]> {
console.log('📤 fetchAutomations: Making API request to /api/automations');
try {
const data = await request({
url: '/api/automations',
method: 'get'
});
console.log('📥 fetchAutomations: API response:', data);
// Handle different response formats
let automations: Automation[] = [];
if (Array.isArray(data)) {
automations = data;
} else if (data && typeof data === 'object') {
if (Array.isArray(data.automations)) {
automations = data.automations;
} else if (Array.isArray(data.items)) {
automations = data.items;
} else if (Array.isArray(data.data)) {
automations = data.data;
}
}
console.log(`✅ fetchAutomations: Returning ${automations.length} automations`);
return automations;
} catch (error) {
console.error('❌ fetchAutomations: Error fetching automations:', error);
throw error;
}
}
/**
* Fetch a single automation by ID
* Endpoint: GET /api/automations/{automationId}
*/
export async function fetchAutomation(
request: ApiRequestFunction,
automationId: string
): Promise<Automation> {
return await request({
url: `/api/automations/${automationId}`,
method: 'get'
});
}
/**
* Create a new automation
* Endpoint: POST /api/automations
*/
export async function createAutomationApi(
request: ApiRequestFunction,
automationData: CreateAutomationRequest
): Promise<Automation> {
return await request({
url: '/api/automations',
method: 'post',
data: automationData
});
}
/**
* Update an existing automation
* Endpoint: PUT /api/automations/{automationId}
*/
export async function updateAutomationApi(
request: ApiRequestFunction,
automationId: string,
updateData: UpdateAutomationRequest
): Promise<Automation> {
return await request({
url: `/api/automations/${automationId}`,
method: 'put',
data: updateData
});
}
/**
* Delete an automation
* Endpoint: DELETE /api/automations/{automationId}
*/
export async function deleteAutomationApi(
request: ApiRequestFunction,
automationId: string
): Promise<void> {
await request({
url: `/api/automations/${automationId}`,
method: 'delete'
});
}
/**
* Execute an automation (test mode)
* Endpoint: POST /api/automations/{automationId}/execute
*/
export async function executeAutomationApi(
request: ApiRequestFunction,
automationId: string
): Promise<ExecuteAutomationResponse> {
return await request({
url: `/api/automations/${automationId}/execute`,
method: 'post'
});
}
/**
* Fetch automation attributes for dynamic form generation
* Endpoint: GET /api/attributes/AutomationDefinition
*/
export async function fetchAutomationAttributes(
request: ApiRequestFunction
): Promise<any[]> {
const data = await request({
url: '/api/attributes/AutomationDefinition',
method: 'get'
});
if (data?.attributes && Array.isArray(data.attributes)) {
return data.attributes;
}
if (Array.isArray(data)) {
return data;
}
return [];
}
// ============================================================================
// AUTOMATION TEMPLATES API
// ============================================================================
/**
* Fetch all automation templates (RBAC-filtered: own templates)
* Endpoint: GET /api/automation-templates
*/
export async function fetchAutomationTemplates(
request: ApiRequestFunction,
params?: any
): Promise<any> {
const requestParams: Record<string, string> = {};
if (params && typeof params === 'object') {
const paginationObj: any = {};
if (params.page !== undefined) paginationObj.page = params.page;
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
}
}
return await request({
url: '/api/automation-templates',
method: 'get',
params: requestParams,
});
}
/**
* Fetch single automation template by ID
* Endpoint: GET /api/automation-templates/{templateId}
*/
export async function fetchAutomationTemplateById(
request: ApiRequestFunction,
templateId: string
): Promise<AutomationTemplate | null> {
try {
return await request({
url: `/api/automation-templates/${templateId}`,
method: 'get'
});
} catch (error) {
console.error('Error fetching template:', error);
return null;
}
}
/**
* Create new automation template
* Endpoint: POST /api/automation-templates
*/
export async function createAutomationTemplateApi(
request: ApiRequestFunction,
templateData: Omit<AutomationTemplate, 'id' | 'sysCreatedAt' | 'sysCreatedBy'>
): Promise<AutomationTemplate> {
return await request({
url: '/api/automation-templates',
method: 'post',
data: templateData
});
}
/**
* Update automation template
* Endpoint: PUT /api/automation-templates/{templateId}
*/
export async function updateAutomationTemplateApi(
request: ApiRequestFunction,
templateId: string,
templateData: Partial<AutomationTemplate>
): Promise<AutomationTemplate> {
return await request({
url: `/api/automation-templates/${templateId}`,
method: 'put',
data: templateData
});
}
/**
* Delete automation template
* Endpoint: DELETE /api/automation-templates/{templateId}
*/
export async function deleteAutomationTemplateApi(
request: ApiRequestFunction,
templateId: string
): Promise<void> {
await request({
url: `/api/automation-templates/${templateId}`,
method: 'delete'
});
}
/**
* Fetch automation template attributes for dynamic form generation
* Endpoint: GET /api/automation-templates/attributes
*/
export async function fetchAutomationTemplateAttributes(
request: ApiRequestFunction
): Promise<any[]> {
const data = await request({
url: '/api/automation-templates/attributes',
method: 'get'
});
// Backend returns: { attributes: { model: "...", attributes: [...] } }
if (data?.attributes?.attributes && Array.isArray(data.attributes.attributes)) {
return data.attributes.attributes;
}
// Fallback: direct attributes array
if (data?.attributes && Array.isArray(data.attributes)) {
return data.attributes;
}
return Array.isArray(data) ? data : [];
}
// ============================================================================
// WORKFLOW ACTIONS API
// ============================================================================
/**
* Fetch available workflow actions (RBAC-filtered)
* Endpoint: GET /api/automations/actions
*/
export async function fetchWorkflowActions(
request: ApiRequestFunction
): Promise<WorkflowAction[]> {
const data = await request({
url: '/api/automations/actions',
method: 'get'
});
return data?.actions || [];
}

File diff suppressed because it is too large Load diff

View file

@ -1,289 +0,0 @@
/* ActionsPanel Styles */
.panel {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-secondary, #f5f5f5);
border-radius: 8px;
overflow: hidden;
}
.header {
padding: 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #ffffff);
}
.title {
margin: 0 0 0.75rem 0;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #333);
}
.searchBox {
display: flex;
align-items: center;
background: var(--bg-secondary, #f5f5f5);
border-radius: 6px;
padding: 0.5rem 0.75rem;
}
.searchIcon {
color: var(--text-secondary, #666);
margin-right: 0.5rem;
font-size: 0.875rem;
}
.searchInput {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
color: var(--text-primary, #333);
outline: none;
}
.searchInput::placeholder {
color: var(--text-tertiary, #999);
}
.actionsList {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.loading,
.error,
.empty {
padding: 2rem;
text-align: center;
color: var(--text-secondary, #666);
}
.error {
color: var(--error-color, #dc3545);
}
.retryButton {
margin-top: 1rem;
padding: 0.5rem 1rem;
background: var(--primary-color, #007bff);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.retryButton:hover {
background: var(--primary-hover, #0056b3);
}
/* Method Groups */
.methodGroup {
margin-bottom: 0.5rem;
background: var(--bg-primary, #ffffff);
border-radius: 6px;
overflow: hidden;
}
.methodHeader {
display: flex;
align-items: center;
width: 100%;
padding: 0.75rem 1rem;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
font-weight: 600;
color: var(--text-primary, #333);
transition: background 0.2s;
}
.methodHeader:hover {
background: var(--bg-hover, #f0f0f0);
}
.methodHeader svg {
margin-right: 0.5rem;
font-size: 0.75rem;
color: var(--text-secondary, #666);
}
.methodName {
flex: 1;
text-transform: capitalize;
}
.methodCount {
background: var(--primary-color, #007bff);
color: white;
padding: 0.125rem 0.5rem;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 500;
}
/* Method Actions */
.methodActions {
border-top: 1px solid var(--border-color, #e0e0e0);
}
.actionItem {
border-bottom: 1px solid var(--border-light, #f0f0f0);
}
.actionItem:last-child {
border-bottom: none;
}
.actionHeader {
display: flex;
align-items: center;
padding: 0.75rem 1rem;
cursor: pointer;
transition: background 0.2s;
}
.actionHeader:hover {
background: var(--bg-hover, #f5f5f5);
}
.actionInfo {
flex: 1;
min-width: 0;
}
.actionName {
display: block;
font-weight: 500;
font-size: 0.875rem;
color: var(--text-primary, #333);
}
.actionDesc {
display: block;
font-size: 0.75rem;
color: var(--text-secondary, #666);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.copyButton {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--bg-secondary, #f5f5f5);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
cursor: pointer;
color: var(--text-secondary, #666);
transition: all 0.2s;
}
.copyButton:hover {
background: var(--primary-color, #007bff);
border-color: var(--primary-color, #007bff);
color: white;
}
/* Action Details */
.actionDetails {
padding: 0.75rem 1rem;
background: var(--bg-secondary, #f8f9fa);
border-top: 1px solid var(--border-light, #f0f0f0);
}
.actionDetails h5 {
margin: 0 0 0.5rem 0;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #666);
text-transform: uppercase;
}
/* Parameters */
.parameters {
margin-bottom: 1rem;
}
.parameters ul {
margin: 0;
padding: 0;
list-style: none;
}
.param {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.8125rem;
}
.paramName {
font-weight: 500;
color: var(--text-primary, #333);
}
.required {
color: var(--error-color, #dc3545);
margin-left: 2px;
}
.paramType {
font-family: monospace;
font-size: 0.75rem;
background: var(--bg-code, #e9ecef);
padding: 0.125rem 0.375rem;
border-radius: 3px;
color: var(--text-secondary, #666);
}
.paramDesc {
width: 100%;
font-size: 0.75rem;
color: var(--text-tertiary, #888);
}
/* Example JSON */
.exampleJson {
margin-bottom: 1rem;
}
.exampleJson pre {
margin: 0;
padding: 0.75rem;
background: var(--bg-code, #1e1e1e);
color: var(--text-code, #d4d4d4);
border-radius: 4px;
font-size: 0.75rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
}
.insertButton {
width: 100%;
padding: 0.5rem 1rem;
background: var(--primary-color, #007bff);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
transition: background 0.2s;
}
.insertButton:hover {
background: var(--primary-hover, #0056b3);
}

View file

@ -1,216 +0,0 @@
/**
* ActionsPanel
*
* Displays available workflow actions for copy/paste into templates.
* Groups actions by method and shows parameters + example JSON.
*/
import React, { useState, useMemo, useEffect } from 'react';
import { useWorkflowActions, type WorkflowAction } from '../../hooks/useAutomations';
import { FaSearch, FaCopy, FaChevronDown, FaChevronRight, FaCheck } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import styles from './ActionsPanel.module.css';
interface ActionsPanelProps {
/** Callback when action JSON is inserted (optional) */
onInsert?: (actionJson: string) => void;
/** Callback when action JSON is copied (optional) */
onCopy?: (actionJson: string) => void;
}
export const ActionsPanel: React.FC<ActionsPanelProps> = ({ onInsert, onCopy }) => {
const { actions, loading, error, fetchActions } = useWorkflowActions();
const { showSuccess } = useToast();
const [filter, setFilter] = useState('');
const [expandedMethods, setExpandedMethods] = useState<Set<string>>(new Set());
const [expandedAction, setExpandedAction] = useState<string | null>(null);
const [copiedAction, setCopiedAction] = useState<string | null>(null);
useEffect(() => {
fetchActions();
}, [fetchActions]);
// Filter actions by search term
const filteredActions = useMemo(() => {
if (!filter) return actions;
const lower = filter.toLowerCase();
return actions.filter(a =>
a.method.toLowerCase().includes(lower) ||
a.action.toLowerCase().includes(lower) ||
a.description.toLowerCase().includes(lower) ||
a.actionId.toLowerCase().includes(lower)
);
}, [actions, filter]);
// Group actions by method
const groupedActions = useMemo(() => {
const groups: Record<string, WorkflowAction[]> = {};
filteredActions.forEach(action => {
if (!groups[action.method]) {
groups[action.method] = [];
}
groups[action.method].push(action);
});
return groups;
}, [filteredActions]);
// Toggle method expansion
const toggleMethod = (method: string) => {
setExpandedMethods(prev => {
const newSet = new Set(prev);
if (newSet.has(method)) {
newSet.delete(method);
} else {
newSet.add(method);
}
return newSet;
});
};
// Toggle action details
const toggleAction = (actionId: string) => {
setExpandedAction(prev => prev === actionId ? null : actionId);
};
// Copy action JSON to clipboard
const handleCopy = async (action: WorkflowAction) => {
const json = JSON.stringify(action.exampleJson, null, 2);
try {
await navigator.clipboard.writeText(json);
setCopiedAction(action.actionId);
setTimeout(() => setCopiedAction(null), 2000);
showSuccess('JSON kopiert');
onCopy?.(json);
} catch (err) {
console.error('Failed to copy:', err);
}
};
// Insert action JSON
const handleInsert = (action: WorkflowAction) => {
const json = JSON.stringify(action.exampleJson, null, 2);
onInsert?.(json);
};
if (loading) {
return (
<div className={styles.panel}>
<div className={styles.loading}>Lade Actions...</div>
</div>
);
}
if (error) {
return (
<div className={styles.panel}>
<div className={styles.error}>Fehler: {error}</div>
<button className={styles.retryButton} onClick={() => fetchActions()}>
Erneut versuchen
</button>
</div>
);
}
return (
<div className={styles.panel}>
<div className={styles.header}>
<h3 className={styles.title}>Verfügbare Actions</h3>
<div className={styles.searchBox}>
<FaSearch className={styles.searchIcon} />
<input
type="text"
placeholder="Suchen..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className={styles.searchInput}
/>
</div>
</div>
<div className={styles.actionsList}>
{Object.keys(groupedActions).length === 0 ? (
<div className={styles.empty}>Keine Actions gefunden</div>
) : (
Object.entries(groupedActions).map(([method, methodActions]) => (
<div key={method} className={styles.methodGroup}>
<button
className={styles.methodHeader}
onClick={() => toggleMethod(method)}
>
{expandedMethods.has(method) ? <FaChevronDown /> : <FaChevronRight />}
<span className={styles.methodName}>{method}</span>
<span className={styles.methodCount}>{methodActions.length}</span>
</button>
{expandedMethods.has(method) && (
<div className={styles.methodActions}>
{methodActions.map(action => (
<div key={action.actionId} className={styles.actionItem}>
<div
className={styles.actionHeader}
onClick={() => toggleAction(action.actionId)}
>
<div className={styles.actionInfo}>
<span className={styles.actionName}>{action.action}</span>
<span className={styles.actionDesc}>{action.description}</span>
</div>
<button
className={styles.copyButton}
onClick={(e) => { e.stopPropagation(); handleCopy(action); }}
title="JSON kopieren"
>
{copiedAction === action.actionId ? <FaCheck /> : <FaCopy />}
</button>
</div>
{expandedAction === action.actionId && (
<div className={styles.actionDetails}>
{action.parameters.length > 0 && (
<div className={styles.parameters}>
<h5>Parameter:</h5>
<ul>
{action.parameters.map(param => (
<li key={param.name} className={styles.param}>
<span className={styles.paramName}>
{param.name}
{param.required && <span className={styles.required}>*</span>}
</span>
<span className={styles.paramType}>{param.type}</span>
{param.description && (
<span className={styles.paramDesc}>{param.description}</span>
)}
</li>
))}
</ul>
</div>
)}
<div className={styles.exampleJson}>
<h5>Beispiel JSON:</h5>
<pre>{JSON.stringify(action.exampleJson, null, 2)}</pre>
</div>
{onInsert && (
<button
className={styles.insertButton}
onClick={() => handleInsert(action)}
>
In Template einfügen
</button>
)}
</div>
)}
</div>
))}
</div>
)}
</div>
))
)}
</div>
</div>
);
};
export default ActionsPanel;

View file

@ -1,2 +0,0 @@
export { ActionsPanel } from './ActionsPanel';
export { default } from './ActionsPanel';

View file

@ -1,130 +0,0 @@
/**
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen) and execute result.
*/
import React from 'react';
import { FaCog, 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;
onWorkflowSettings?: () => void;
saving: boolean;
executing: boolean;
hasNodes: boolean;
executeResult: ExecuteGraphResponse | null;
}
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
workflows,
currentWorkflowId,
onWorkflowSelect,
onNew,
onSave,
onExecute,
onWorkflowSettings,
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>
{onWorkflowSettings && (
<button
type="button"
className={styles.canvasGearBtn}
title="Workflow-Konfiguration (Einstieg / Starts)"
aria-label="Workflow-Konfiguration"
onClick={onWorkflowSettings}
>
<FaCog />
</button>
)}
<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>
);

View file

@ -1,946 +0,0 @@
/**
* AutomationEditor Styles
*
* Full-screen editor with form on left and actions panel on right
*/
/* Used when AutomationEditor had custom overlay - kept for reference, Popup is used now */
.editorOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
/* Popup customisation for fullscreen editor - fill content area */
.editorPopup :global([class*="content"]) {
padding: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.editorContainer {
background: var(--surface-color, #ffffff);
border-radius: 12px;
width: 100%;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Header */
.editorHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #ffffff);
flex-shrink: 0;
}
.headerLeft {
display: flex;
align-items: center;
gap: 1rem;
}
.editorTitle {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #333);
margin: 0;
}
.modeBadge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
}
.modeBadge.template {
background: var(--info-bg, #e3f2fd);
color: var(--info-color, #1976d2);
}
.modeBadge.definition {
background: var(--success-bg, #e8f5e9);
color: var(--success-color, #388e3c);
}
.headerActions {
display: flex;
align-items: center;
gap: 0.75rem;
}
.closeButton {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: transparent;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
cursor: pointer;
color: var(--text-secondary, #666);
font-size: 1.125rem;
transition: all 0.2s;
}
.closeButton:hover {
background: var(--bg-secondary, #f5f5f5);
color: var(--text-primary, #333);
}
/* Main Content Area */
.editorContent {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
/* Form Panel (Left Side) */
.formPanel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid var(--border-color, #e0e0e0);
}
.formPanelHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.5rem;
background: var(--bg-secondary, #f5f5f5);
border-bottom: 1px solid var(--border-color, #e0e0e0);
flex-shrink: 0;
}
.formPanelTitle {
font-size: 0.875rem;
font-weight: 600;
color: var(--text-secondary, #666);
margin: 0;
}
.formPanelContent {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
}
/* Actions Panel (Right Side) */
.actionsPanel {
width: 400px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--bg-secondary, #f8f9fa);
}
.actionsPanelCollapsed {
width: 48px;
}
.actionsPanelToggle {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0.75rem;
background: var(--bg-secondary, #f5f5f5);
border: none;
border-bottom: 1px solid var(--border-color, #e0e0e0);
cursor: pointer;
color: var(--text-secondary, #666);
font-size: 0.875rem;
font-weight: 500;
transition: all 0.2s;
flex-shrink: 0;
}
.actionsPanelToggle:hover {
background: var(--bg-hover, #e8e8e8);
color: var(--text-primary, #333);
}
.actionsPanelToggle svg {
margin-right: 0.5rem;
}
.actionsPanelCollapsed .actionsPanelToggle {
writing-mode: vertical-rl;
text-orientation: mixed;
padding: 1rem 0.75rem;
height: 100%;
}
.actionsPanelCollapsed .actionsPanelToggle svg {
margin-right: 0;
margin-bottom: 0.5rem;
transform: rotate(90deg);
}
.actionsPanelContainer {
flex: 1;
overflow: hidden;
}
/* Footer */
.editorFooter {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-primary, #ffffff);
flex-shrink: 0;
}
.footerLeft {
display: flex;
align-items: center;
gap: 1rem;
}
.footerRight {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Buttons */
.primaryButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: var(--primary-color, #f25843);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, transform 0.1s;
}
.primaryButton:hover:not(:disabled) {
background: var(--primary-dark, #d94d3a);
}
.primaryButton:active:not(:disabled) {
transform: scale(0.98);
}
.primaryButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.secondaryButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: var(--surface-color, #ffffff);
color: var(--text-primary, #333);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
}
.secondaryButton:hover:not(:disabled) {
background: var(--bg-secondary, #f5f5f5);
border-color: var(--text-secondary, #666);
}
.secondaryButton:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.dangerButton {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: var(--danger-color, #dc3545);
color: white;
border: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.dangerButton:hover:not(:disabled) {
background: var(--danger-dark, #c82333);
}
/* JSON Editor Section */
.jsonEditorSection {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.jsonEditorHeader {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.jsonEditorLabelRow {
display: flex;
justify-content: space-between;
align-items: center;
}
.jsonEditorLabel {
display: flex;
align-items: center;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.jsonEditorHint {
font-size: 0.75rem;
color: var(--text-tertiary, #999);
}
.formatButton {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.375rem 0.75rem;
background: var(--bg-secondary, #f5f5f5);
color: var(--text-secondary, #666);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.formatButton:hover {
background: var(--primary-color, #f25843);
color: white;
border-color: var(--primary-color, #f25843);
}
.jsonTextarea {
width: 100%;
min-height: 300px;
padding: 1rem;
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
font-size: 0.8125rem;
line-height: 1.5;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
background: var(--bg-code, #1e1e1e);
color: var(--text-code, #d4d4d4);
resize: vertical;
tab-size: 2;
}
.jsonTextarea:focus {
outline: none;
border-color: var(--primary-color, #f25843);
box-shadow: 0 0 0 3px rgba(242, 88, 67, 0.1);
}
.jsonTextarea.error {
border-color: var(--danger-color, #dc3545);
}
.jsonError {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--danger-bg, #fef2f2);
color: var(--danger-color, #dc3545);
border-radius: 4px;
font-size: 0.8125rem;
}
/* Placeholders Section */
.placeholdersSection {
margin-top: 1.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border-color, #e0e0e0);
}
.placeholdersHeader {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
}
.placeholdersTitle {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.placeholdersHint {
font-size: 0.75rem;
color: var(--text-tertiary, #999);
}
.placeholdersList {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.placeholderItem {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 6px;
}
.placeholderKeyRow {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.placeholderKey {
padding: 0.375rem 0.625rem;
background: var(--bg-code, #e9ecef);
border-radius: 4px;
font-family: monospace;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.placeholderDescription {
font-size: 0.75rem;
color: var(--text-secondary, #666);
flex: 1;
}
.placeholderType {
padding: 0.25rem 0.5rem;
background: var(--info-bg, #e3f2fd);
color: var(--info-color, #1976d2);
border-radius: 4px;
font-size: 0.6875rem;
font-weight: 500;
text-transform: uppercase;
}
.placeholderError {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
background: var(--error-bg, #ffebee);
color: var(--error-color, #c62828);
border: 1px solid var(--error-border, #ef9a9a);
border-radius: 6px;
font-size: 0.8125rem;
}
.placeholderError svg {
flex-shrink: 0;
}
.sharepointFolderInput {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.sharepointFolderHint {
font-size: 0.75rem;
color: var(--text-secondary, #666);
font-style: italic;
}
/* SharePoint Folder Picker */
.sharepointFolderPicker {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.sharepointFolderHeader {
display: flex;
gap: 0.5rem;
align-items: center;
}
.sharepointFolderHeader .placeholderInput {
flex: 1;
}
.sharepointBrowseButton {
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
background: var(--secondary-button-bg, #f0f0f0);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
color: var(--text-primary, #333);
white-space: nowrap;
transition: background-color 0.15s;
}
.sharepointBrowseButton:hover {
background: var(--secondary-button-hover-bg, #e0e0e0);
}
.sharepointFolderBrowser {
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
padding: 1rem;
background: var(--bg-secondary, #fafafa);
}
.sharepointError {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--danger-bg, #fff0f0);
color: var(--danger-color, #d32f2f);
border-radius: 4px;
margin-bottom: 0.75rem;
font-size: 0.875rem;
}
.sharepointSection {
margin-bottom: 1rem;
}
.sharepointSection:last-child {
margin-bottom: 0;
}
.sharepointSection label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: var(--text-secondary, #666);
margin-bottom: 0.25rem;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.sharepointLoading {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
color: var(--text-secondary, #666);
font-size: 0.875rem;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.sharepointSelect {
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, white);
cursor: pointer;
}
.sharepointSelect:focus {
outline: none;
border-color: var(--primary-color, #1976d2);
}
.sharepointBreadcrumb {
font-size: 0.75rem;
color: var(--text-secondary, #666);
margin-bottom: 0.5rem;
padding: 0.25rem 0;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.sharepointFolderList {
max-height: 200px;
overflow-y: auto;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 4px;
background: var(--bg-primary, white);
}
.sharepointFolderItem {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
font-size: 0.875rem;
border-bottom: 1px solid var(--border-light, #f0f0f0);
transition: background-color 0.1s;
}
.sharepointFolderItem:last-child {
border-bottom: none;
}
.sharepointFolderItem:hover {
background: var(--bg-hover, #f5f5f5);
}
.sharepointFolderItem .folderName {
flex: 1;
cursor: pointer;
}
.sharepointFolderItem .folderName:hover {
text-decoration: underline;
}
.selectFolderButton {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
background: var(--primary-color, #1976d2);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s;
}
.sharepointFolderItem:hover .selectFolderButton {
opacity: 1;
}
.selectFolderButton:hover {
background: var(--primary-hover, #1565c0);
}
.sharepointEmpty {
padding: 1rem;
text-align: center;
color: var(--text-secondary, #666);
font-size: 0.875rem;
font-style: italic;
}
.selectCurrentFolderButton {
width: 100%;
margin-top: 0.75rem;
padding: 0.5rem 0.75rem;
background: var(--success-color, #2e7d32);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.875rem;
transition: background-color 0.15s;
}
.selectCurrentFolderButton:hover {
background: var(--success-hover, #1b5e20);
}
.placeholderInput {
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, #ffffff);
color: var(--text-primary, #333);
}
.placeholderInput:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.placeholderSelect {
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, #ffffff);
color: var(--text-primary, #333);
cursor: pointer;
}
.placeholderSelect:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.placeholderSelect:disabled {
background: var(--bg-secondary, #f5f5f5);
cursor: not-allowed;
}
.placeholderTextarea {
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, #ffffff);
color: var(--text-primary, #333);
resize: vertical;
min-height: 60px;
}
.placeholderTextarea:focus {
outline: none;
border-color: var(--primary-color, #f25843);
}
.placeholderCheckbox {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-primary, #333);
cursor: pointer;
}
.placeholderCheckbox input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary-color, #f25843);
cursor: pointer;
}
.noPlaceholders {
padding: 1rem;
text-align: center;
color: var(--text-tertiary, #999);
font-size: 0.875rem;
background: var(--bg-secondary, #f5f5f5);
border-radius: 6px;
}
/* Form Fields */
.formFields {
display: flex;
flex-direction: column;
gap: 1rem;
}
.formGroup {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.formLabel {
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
}
.formLabel .required {
color: var(--danger-color, #dc3545);
}
.formInput {
padding: 0.625rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.875rem;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #333);
transition: border-color 0.2s, box-shadow 0.2s;
}
.formInput:focus {
outline: none;
border-color: var(--primary-color, #f25843);
box-shadow: 0 0 0 3px rgba(242, 88, 67, 0.1);
}
.formTextarea {
padding: 0.625rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.875rem;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #333);
resize: vertical;
min-height: 80px;
transition: border-color 0.2s, box-shadow 0.2s;
}
.formTextarea:focus {
outline: none;
border-color: var(--primary-color, #f25843);
box-shadow: 0 0 0 3px rgba(242, 88, 67, 0.1);
}
.formHint {
font-size: 0.75rem;
color: var(--text-tertiary, #999);
margin: 0;
}
.checkboxLabel {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-primary, #333);
cursor: pointer;
}
.checkboxLabel input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: var(--primary-color, #f25843);
cursor: pointer;
}
/* Language Tabs */
.languageTabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
padding-bottom: 0.5rem;
}
.languageTab {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid transparent;
border-bottom: none;
border-radius: 6px 6px 0 0;
font-size: 0.8125rem;
font-weight: 500;
color: var(--text-secondary, #666);
cursor: pointer;
transition: all 0.2s;
}
.languageTab:hover {
background: var(--bg-secondary, #f5f5f5);
color: var(--text-primary, #333);
}
.languageTab.active {
background: var(--bg-primary, #ffffff);
border-color: var(--border-color, #e0e0e0);
color: var(--primary-color, #f25843);
border-bottom: 2px solid var(--primary-color, #f25843);
margin-bottom: -1px;
}
/* Loading State */
.loadingState {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
color: var(--text-secondary, #666);
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid var(--border-color, #e0e0e0);
border-top-color: var(--primary-color, #f25843);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 1rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Responsive */
@media (max-width: 1200px) {
.actionsPanel {
width: 350px;
}
}
@media (max-width: 900px) {
.editorContent {
flex-direction: column;
}
.formPanel {
border-right: none;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.actionsPanel {
width: 100%;
height: 300px;
}
.actionsPanelCollapsed {
width: 100%;
height: 48px;
}
.actionsPanelCollapsed .actionsPanelToggle {
writing-mode: horizontal-tb;
text-orientation: mixed;
padding: 0.75rem;
height: auto;
}
.actionsPanelCollapsed .actionsPanelToggle svg {
margin-bottom: 0;
margin-right: 0.5rem;
transform: none;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,2 +0,0 @@
export { AutomationEditor, type AutomationEditorProps, type EditorMode } from './AutomationEditor';
export { default } from './AutomationEditor';

View file

@ -0,0 +1,102 @@
/**
* ChatInput -- Shared chat input component.
*
* Simple text input with send button, usable by both Workspace and Editor.
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
interface ChatInputProps {
onSend: (message: string) => void;
isProcessing?: boolean;
placeholder?: string;
disabled?: boolean;
autoFocus?: boolean;
style?: React.CSSProperties;
}
export const ChatInput: React.FC<ChatInputProps> = ({
onSend,
isProcessing,
placeholder = 'Type a message...',
disabled,
autoFocus = true,
style,
}) => {
const [value, setValue] = useState('');
const inputRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (autoFocus) inputRef.current?.focus();
}, [autoFocus]);
const _handleSend = useCallback(() => {
const trimmed = value.trim();
if (!trimmed || isProcessing || disabled) return;
onSend(trimmed);
setValue('');
}, [value, isProcessing, disabled, onSend]);
const _handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
_handleSend();
}
},
[_handleSend]
);
return (
<div
style={{
display: 'flex',
gap: '8px',
padding: '8px 12px',
borderTop: '1px solid var(--border-color, #e0e0e0)',
alignItems: 'flex-end',
...style,
}}
>
<textarea
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={_handleKeyDown}
placeholder={placeholder}
disabled={isProcessing || disabled}
rows={1}
style={{
flex: 1,
resize: 'none',
border: '1px solid var(--border-color, #ddd)',
borderRadius: '8px',
padding: '8px 12px',
fontSize: '13px',
fontFamily: 'inherit',
outline: 'none',
minHeight: '36px',
maxHeight: '120px',
background: 'var(--bg-primary, #fff)',
color: 'var(--text-primary, #333)',
}}
/>
<button
onClick={_handleSend}
disabled={!value.trim() || isProcessing || disabled}
style={{
padding: '8px 16px',
borderRadius: '8px',
border: 'none',
background: !value.trim() || isProcessing || disabled ? '#ccc' : 'var(--color-primary, #2563eb)',
color: '#fff',
fontSize: '13px',
fontWeight: 600,
cursor: !value.trim() || isProcessing || disabled ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap',
}}
>
{isProcessing ? '...' : 'Send'}
</button>
</div>
);
};

View file

@ -0,0 +1,89 @@
/**
* ChatMessageList -- Shared chat message display component.
*
* Renders a scrollable list of messages with Markdown support.
* Used by both the Workspace ChatStream and the Editor ChatPanel.
*/
import React, { useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
export interface ChatMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp?: number;
}
interface ChatMessageListProps {
messages: ChatMessage[];
isProcessing?: boolean;
emptyMessage?: string;
style?: React.CSSProperties;
}
const _roleColors: Record<string, string> = {
user: 'var(--color-primary, #2563eb)',
assistant: 'var(--text-primary, #333)',
system: 'var(--text-secondary, #888)',
};
export const ChatMessageList: React.FC<ChatMessageListProps> = ({
messages,
isProcessing,
emptyMessage = 'No messages yet.',
style,
}) => {
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div
style={{
flex: 1,
minHeight: 0,
overflowY: 'auto',
padding: '12px 16px',
display: 'flex',
flexDirection: 'column',
gap: 8,
...style,
}}
>
{messages.length === 0 && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '13px', textAlign: 'center', marginTop: '24px' }}>
{emptyMessage}
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
style={{
padding: '8px 12px',
borderRadius: '8px',
background: msg.role === 'user' ? 'var(--bg-secondary, #f5f5f5)' : 'transparent',
fontSize: '13px',
lineHeight: 1.5,
color: _roleColors[msg.role] || 'var(--text-primary, #333)',
}}
>
<div style={{ fontWeight: 600, fontSize: '11px', marginBottom: '4px', textTransform: 'uppercase', color: 'var(--text-secondary, #888)' }}>
{msg.role}
</div>
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{msg.content}
</ReactMarkdown>
</div>
))}
{isProcessing && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '12px', fontStyle: 'italic' }}>
Processing...
</div>
)}
<div ref={bottomRef} />
</div>
);
};

View file

@ -0,0 +1,3 @@
export { ChatMessageList } from './ChatMessageList';
export type { ChatMessage } from './ChatMessageList';
export { ChatInput } from './ChatInput';

View file

@ -5,7 +5,7 @@
import React, { createContext, useContext, useMemo } from 'react';
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
import type { NodeType } from '../../../api/automation2Api';
import type { NodeType } from '../../../api/workflowApi';
export interface Automation2DataFlowContextValue {
currentNodeId: string;

View file

@ -15,13 +15,19 @@ import {
fetchWorkflow,
createWorkflow,
updateWorkflow,
fetchVersions,
createDraftVersion,
publishVersion,
unpublishVersion,
archiveVersion,
type NodeType,
type NodeTypeCategory,
type Automation2Graph,
type Automation2Workflow,
type ExecuteGraphResponse,
type WorkflowEntryPoint,
} from '../../../api/automation2Api';
type AutoVersion,
} from '../../../api/workflowApi';
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
import { NodeConfigPanel } from './NodeConfigPanel';
import { NodeSidebar } from './NodeSidebar';
@ -36,6 +42,8 @@ import {
import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
import { usePrompt } from '../../../hooks/usePrompt';
import { EditorChatPanel } from './EditorChatPanel';
import { RunTracingPanel } from './RunTracingPanel';
import styles from './Automation2FlowEditor.module.css';
const LOG = '[Automation2]';
@ -75,6 +83,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
const [saving, setSaving] = useState(false);
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(DEFAULT_INVOCATIONS);
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
const [chatPanelOpen, setChatPanelOpen] = useState(false);
const [tracingRunId, setTracingRunId] = useState<string | null>(null);
const [versions, setVersions] = useState<AutoVersion[]>([]);
const [currentVersionId, setCurrentVersionId] = useState<string | null>(null);
const [versionLoading, setVersionLoading] = useState(false);
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []);
@ -123,6 +136,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
...(ep ? { entryPointId: ep } : {}),
});
setExecuteResult(result);
if (result.runId) setTracingRunId(result.runId);
} catch (err: unknown) {
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
} finally {
@ -346,6 +360,98 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
[nodeTypes, language]
);
const loadVersions = useCallback(async () => {
if (!instanceId || !currentWorkflowId) {
setVersions([]);
return;
}
try {
const v = await fetchVersions(request, instanceId, currentWorkflowId);
setVersions(v);
} catch (e) {
console.error(`${LOG} loadVersions failed`, e);
}
}, [instanceId, currentWorkflowId, request]);
useEffect(() => {
loadVersions();
}, [loadVersions]);
const handleVersionSelect = useCallback(
(versionId: string | null) => {
setCurrentVersionId(versionId);
if (versionId) {
const v = versions.find((ver) => ver.id === versionId);
if (v?.graph) {
handleFromApiGraph(v.graph, v.invocations);
}
}
},
[versions, handleFromApiGraph]
);
const handlePublishVersion = useCallback(
async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true);
try {
await publishVersion(request, instanceId, versionId);
await loadVersions();
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
} finally {
setVersionLoading(false);
}
},
[request, instanceId, loadVersions]
);
const handleUnpublishVersion = useCallback(
async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true);
try {
await unpublishVersion(request, instanceId, versionId);
await loadVersions();
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
} finally {
setVersionLoading(false);
}
},
[request, instanceId, loadVersions]
);
const handleArchiveVersion = useCallback(
async (versionId: string) => {
if (!instanceId) return;
setVersionLoading(true);
try {
await archiveVersion(request, instanceId, versionId);
await loadVersions();
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
} finally {
setVersionLoading(false);
}
},
[request, instanceId, loadVersions]
);
const handleCreateDraft = useCallback(async () => {
if (!instanceId || !currentWorkflowId) return;
setVersionLoading(true);
try {
const draft = await createDraftVersion(request, instanceId, currentWorkflowId);
await loadVersions();
setCurrentVersionId(draft.id);
} catch (e: unknown) {
setExecuteResult({ success: false, error: e instanceof Error ? e.message : String(e) });
} finally {
setVersionLoading(false);
}
}, [request, instanceId, currentWorkflowId, loadVersions]);
const renderSidebar = () => {
if (loading) {
return (
@ -408,10 +514,19 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
onSave={handleSave}
onExecute={handleExecute}
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
onToggleChat={() => setChatPanelOpen((prev) => !prev)}
saving={saving}
executing={executing}
hasNodes={canvasNodes.length > 0}
executeResult={executeResult}
versions={versions}
currentVersionId={currentVersionId}
onVersionSelect={handleVersionSelect}
onPublishVersion={handlePublishVersion}
onUnpublishVersion={handleUnpublishVersion}
onArchiveVersion={handleArchiveVersion}
onCreateDraft={handleCreateDraft}
versionLoading={versionLoading}
/>
<div className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
<div style={{ flex: 1, minWidth: 0 }}>
@ -450,6 +565,37 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
)}
</div>
</div>
{(chatPanelOpen || tracingRunId) && (
<div style={{ width: 340, borderLeft: '1px solid var(--border-color, #e0e0e0)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<button
onClick={() => { setChatPanelOpen(true); setTracingRunId(null); }}
style={{ flex: 1, padding: '8px', border: 'none', background: chatPanelOpen ? 'var(--bg-secondary, #f5f5f5)' : 'transparent', cursor: 'pointer', fontSize: '12px', fontWeight: 600 }}
>
Chat
</button>
<button
onClick={() => { setChatPanelOpen(false); setTracingRunId(tracingRunId || 'select'); }}
style={{ flex: 1, padding: '8px', border: 'none', background: !chatPanelOpen && tracingRunId ? 'var(--bg-secondary, #f5f5f5)' : 'transparent', cursor: 'pointer', fontSize: '12px', fontWeight: 600 }}
>
Tracing
</button>
<button
onClick={() => { setChatPanelOpen(false); setTracingRunId(null); }}
style={{ padding: '8px 12px', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '14px' }}
>
×
</button>
</div>
<div style={{ flex: 1, overflow: 'hidden' }}>
{chatPanelOpen && currentWorkflowId ? (
<EditorChatPanel instanceId={instanceId} workflowId={currentWorkflowId} />
) : tracingRunId ? (
<RunTracingPanel instanceId={instanceId} runId={tracingRunId === 'select' ? null : tracingRunId} />
) : null}
</div>
</div>
)}
<PromptDialog />
<WorkflowConfigurationModal
open={workflowSettingsOpen}

View file

@ -0,0 +1,250 @@
/**
* CanvasHeader - Workflow controls (Neu, Speichern, laden, Ausführen), version selector, and execute result.
*/
import React from 'react';
import { FaCog, FaPlay, FaSpinner, FaCloudUploadAlt, FaCloudDownloadAlt, FaArchive } from 'react-icons/fa';
import type { Automation2Workflow, ExecuteGraphResponse, AutoVersion } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
interface CanvasHeaderProps {
workflows: Automation2Workflow[];
currentWorkflowId: string | null;
onWorkflowSelect: (workflowId: string | null) => void;
onNew: () => void;
onSave: () => void;
onExecute: () => void;
onWorkflowSettings?: () => void;
onToggleChat?: () => void;
saving: boolean;
executing: boolean;
hasNodes: boolean;
executeResult: ExecuteGraphResponse | null;
versions?: AutoVersion[];
currentVersionId?: string | null;
onVersionSelect?: (versionId: string | null) => void;
onPublishVersion?: (versionId: string) => void;
onUnpublishVersion?: (versionId: string) => void;
onArchiveVersion?: (versionId: string) => void;
onCreateDraft?: () => void;
versionLoading?: boolean;
}
const STATUS_BADGE: Record<string, { label: string; color: string }> = {
draft: { label: 'Entwurf', color: 'var(--warning-color, #ffc107)' },
published: { label: 'Veröffentlicht', color: 'var(--success-color, #28a745)' },
archived: { label: 'Archiviert', color: 'var(--text-secondary, #666)' },
};
export const CanvasHeader: React.FC<CanvasHeaderProps> = ({
workflows,
currentWorkflowId,
onWorkflowSelect,
onNew,
onSave,
onExecute,
onWorkflowSettings,
onToggleChat,
saving,
executing,
hasNodes,
executeResult,
versions,
currentVersionId,
onVersionSelect,
onPublishVersion,
onUnpublishVersion,
onArchiveVersion,
onCreateDraft,
versionLoading,
}) => {
const currentVersion = versions?.find((v) => v.id === currentVersionId);
const currentStatus = currentVersion?.status || 'draft';
const badge = STATUS_BADGE[currentStatus] || STATUS_BADGE.draft;
return (
<div className={styles.canvasHeader}>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<h4 className={styles.canvasTitle} style={{ margin: 0 }}>
Workflow-Editor
</h4>
{onWorkflowSettings && (
<button
type="button"
className={styles.canvasGearBtn}
title="Workflow-Konfiguration (Einstieg / Starts)"
aria-label="Workflow-Konfiguration"
onClick={onWorkflowSettings}
>
<FaCog />
</button>
)}
<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>
{onToggleChat && (
<button type="button" className={styles.retryButton} onClick={onToggleChat} title="AI Chat öffnen">
Chat
</button>
)}
</div>
{/* Version Selector */}
{currentWorkflowId && versions && versions.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginTop: '0.5rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.8rem', fontWeight: 600, color: 'var(--text-secondary, #666)' }}>Version:</span>
<select
value={currentVersionId ?? ''}
onChange={(e) => onVersionSelect?.(e.target.value || null)}
style={{ padding: '0.3rem', minWidth: 140, fontSize: '0.85rem' }}
disabled={versionLoading}
>
<option value=""> Aktuelle </option>
{versions.map((v) => (
<option key={v.id} value={v.id}>
v{v.versionNumber} ({STATUS_BADGE[v.status]?.label ?? v.status})
</option>
))}
</select>
<span
style={{
padding: '2px 8px',
borderRadius: 10,
fontSize: '0.75rem',
fontWeight: 600,
background: badge.color + '22',
color: badge.color,
}}
>
{badge.label}
</span>
{currentVersion && currentStatus === 'draft' && onPublishVersion && (
<button
type="button"
className={styles.retryButton}
onClick={() => onPublishVersion(currentVersion.id)}
disabled={versionLoading}
title="Version veröffentlichen"
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaCloudUploadAlt style={{ marginRight: 4 }} />
Publish
</button>
)}
{currentVersion && currentStatus === 'published' && onUnpublishVersion && (
<button
type="button"
className={styles.retryButton}
onClick={() => onUnpublishVersion(currentVersion.id)}
disabled={versionLoading}
title="Veröffentlichung zurücknehmen"
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaCloudDownloadAlt style={{ marginRight: 4 }} />
Unpublish
</button>
)}
{currentVersion && currentStatus !== 'archived' && onArchiveVersion && (
<button
type="button"
className={styles.retryButton}
onClick={() => onArchiveVersion(currentVersion.id)}
disabled={versionLoading}
title="Version archivieren"
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
<FaArchive style={{ marginRight: 4 }} />
Archiv
</button>
)}
{onCreateDraft && (
<button
type="button"
className={styles.retryButton}
onClick={onCreateDraft}
disabled={versionLoading}
title="Neuen Entwurf erstellen"
style={{ fontSize: '0.8rem', padding: '0.25rem 0.6rem' }}
>
+ Entwurf
</button>
)}
{versionLoading && <FaSpinner className={styles.spinner} style={{ fontSize: '0.85rem' }} />}
</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>
);
};

View file

@ -0,0 +1,145 @@
/**
* EditorChatPanel
*
* AI Chat sidebar for the GraphicalEditor.
* Uses shared ChatMessageList and ChatInput components.
* Streams responses via SSE (same pattern as Workspace chat).
*/
import React, { useState, useCallback, useRef } from 'react';
import { startSseStream } from '../../../utils/sseClient';
import { ChatMessageList, ChatInput } from '../../Chat';
import type { ChatMessage } from '../../Chat';
interface EditorChatPanelProps {
instanceId: string;
workflowId: string | null;
onGraphUpdated?: () => void;
}
let _msgCounter = 0;
export const EditorChatPanel: React.FC<EditorChatPanelProps> = ({
instanceId,
workflowId,
onGraphUpdated,
}) => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [loading, setLoading] = useState(false);
const abortRef = useRef<(() => void) | null>(null);
const _handleSend = useCallback((text: string) => {
if (!workflowId || loading) return;
const userMsg: ChatMessage = {
id: `user-${++_msgCounter}`,
role: 'user',
content: text,
timestamp: Date.now(),
};
setMessages((prev) => [...prev, userMsg]);
setLoading(true);
const assistantId = `asst-${++_msgCounter}`;
let accumulated = '';
setMessages((prev) => [
...prev,
{ id: assistantId, role: 'assistant', content: '', timestamp: Date.now() },
]);
const conversationHistory = messages.map((m) => ({
role: m.role,
message: m.content,
}));
const cleanup = startSseStream({
url: `/api/workflows/${instanceId}/${workflowId}/chat/stream`,
body: {
message: text,
conversationHistory,
userLanguage: navigator.language?.slice(0, 2) || 'de',
},
handlers: {
onChunk: (event) => {
if (event.content) {
accumulated += event.content;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: accumulated } : m
)
);
}
},
onRawEvent: (event) => {
if (event.type === 'message' && event.content) {
accumulated += event.content;
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: accumulated } : m
)
);
}
if (event.type === 'toolResult' || event.type === 'toolCall') {
onGraphUpdated?.();
}
},
onComplete: () => {
if (!accumulated) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: 'Done.' } : m
)
);
}
onGraphUpdated?.();
setLoading(false);
},
onError: (event) => {
const errText = event.content || 'Request failed';
if (!accumulated) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId ? { ...m, content: `Error: ${errText}` } : m
)
);
}
setLoading(false);
},
onStopped: () => {
setLoading(false);
},
},
onConnectionError: (err) => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, content: `Error: ${err.message}` }
: m
)
);
setLoading(false);
},
onStreamEnd: () => {
setLoading(false);
},
});
abortRef.current = cleanup;
}, [loading, workflowId, instanceId, messages, onGraphUpdated]);
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', background: 'var(--bg-secondary, #fafafa)' }}>
<ChatMessageList
messages={messages}
isProcessing={loading}
emptyMessage="Describe what you want to build. The AI will create and modify nodes on the canvas."
/>
<ChatInput
onSend={_handleSend}
isProcessing={loading}
disabled={!workflowId}
placeholder={workflowId ? 'Describe a change...' : 'Save workflow first'}
/>
</div>
);
};

View file

@ -4,7 +4,7 @@
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { NodeType } from '../../../api/automation2Api';
import type { NodeType } from '../../../api/workflowApi';
import styles from './Automation2FlowEditor.module.css';
export interface CanvasNode {

View file

@ -5,8 +5,8 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { CanvasNode } from './FlowCanvas';
import type { NodeType } from '../../../api/automation2Api';
import type { ApiRequestFunction } from '../../../api/automation2Api';
import type { NodeType } from '../../../api/workflowApi';
import type { ApiRequestFunction } from '../../../api/workflowApi';
import { getLabel } from '../nodes/shared/utils';
import { NODE_CONFIG_REGISTRY } from '../nodes/configs';
import styles from './Automation2FlowEditor.module.css';

View file

@ -4,7 +4,7 @@
*/
import React from 'react';
import type { NodeType } from '../../../api/automation2Api';
import type { NodeType } from '../../../api/workflowApi';
import { getCategoryIcon } from '../nodes/shared/utils';
import type { GetLabelFn } from '../nodes/shared/utils';
import styles from './Automation2FlowEditor.module.css';

View file

@ -5,7 +5,7 @@
import React, { useMemo } from 'react';
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
import type { NodeType, NodeTypeCategory } from '../../../api/automation2Api';
import type { NodeType, NodeTypeCategory } from '../../../api/workflowApi';
import { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants';
import { getLabel } from '../nodes/shared/utils';
import { NodeListItem } from './NodeListItem';

View file

@ -0,0 +1,116 @@
/**
* RunTracingPanel
*
* Shows AutoStepLog entries for a workflow run with live-update capability.
* Displays per-node status (running/completed/failed/skipped) with timing info.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from '../../../hooks/useApi';
import type { AutoStepLog } from '../../../api/workflowApi';
interface RunTracingPanelProps {
instanceId: string;
runId: string | null;
onNodeSelect?: (nodeId: string) => void;
}
const STATUS_COLORS: Record<string, string> = {
pending: '#999',
running: '#f0ad4e',
completed: '#28a745',
failed: '#dc3545',
skipped: '#6c757d',
};
const STATUS_ICONS: Record<string, string> = {
pending: '○',
running: '◉',
completed: '✓',
failed: '✗',
skipped: '—',
};
export const RunTracingPanel: React.FC<RunTracingPanelProps> = ({
instanceId,
runId,
onNodeSelect,
}) => {
const [steps, setSteps] = useState<AutoStepLog[]>([]);
const [loading, setLoading] = useState(false);
const { request } = useApiRequest();
const loadSteps = useCallback(async () => {
if (!runId || !instanceId) return;
setLoading(true);
try {
const data = await request({
url: `/api/workflows/${instanceId}/runs/${runId}/steps`,
method: 'get',
});
setSteps(data?.steps || []);
} catch (e) {
console.error('[RunTracing] Failed to load steps:', e);
} finally {
setLoading(false);
}
}, [runId, instanceId, request]);
useEffect(() => {
loadSteps();
const interval = setInterval(loadSteps, 3000);
return () => clearInterval(interval);
}, [loadSteps]);
if (!runId) {
return (
<div style={{ padding: '16px', color: 'var(--text-secondary, #888)', fontSize: '13px' }}>
Select a run to see tracing details.
</div>
);
}
return (
<div style={{ padding: '12px', overflowY: 'auto', height: '100%' }}>
<div style={{ fontWeight: 600, fontSize: '14px', marginBottom: '12px' }}>
Run Steps {loading && <span style={{ fontWeight: 400, fontSize: '12px', color: '#888' }}>(loading...)</span>}
</div>
{steps.length === 0 && !loading && (
<div style={{ color: 'var(--text-secondary, #888)', fontSize: '13px' }}>No steps recorded yet.</div>
)}
{steps.map((step) => (
<div
key={step.id}
onClick={() => onNodeSelect?.(step.nodeId)}
style={{
padding: '8px 12px',
marginBottom: '6px',
borderRadius: '6px',
border: `1px solid ${STATUS_COLORS[step.status] || '#ddd'}`,
background: 'var(--bg-primary, #fff)',
cursor: 'pointer',
fontSize: '13px',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>
<span style={{ color: STATUS_COLORS[step.status] || '#999', marginRight: '6px' }}>
{STATUS_ICONS[step.status] || '?'}
</span>
<strong>{step.nodeType}</strong>
<span style={{ color: '#888', marginLeft: '6px' }}>({step.nodeId})</span>
</span>
{step.durationMs != null && (
<span style={{ color: '#888', fontSize: '12px' }}>{step.durationMs}ms</span>
)}
</div>
{step.error && (
<div style={{ color: '#dc3545', fontSize: '12px', marginTop: '4px' }}>{step.error}</div>
)}
{step.tokensUsed > 0 && (
<div style={{ color: '#888', fontSize: '11px', marginTop: '2px' }}>{step.tokensUsed} tokens</div>
)}
</div>
))}
</div>
);
};

View file

@ -3,7 +3,7 @@
*/
import React, { useState, useEffect } from 'react';
import type { WorkflowEntryPoint } from '../../../api/automation2Api';
import type { WorkflowEntryPoint } from '../../../api/workflowApi';
import {
getPrimaryStartKind,
buildInvocationsForPrimaryKind,

View file

@ -1,4 +1,4 @@
export { Automation2FlowEditor } from './editor/Automation2FlowEditor';
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
export { FlowCanvas } from './editor/FlowCanvas';
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
export { NodeConfigPanel } from './editor/NodeConfigPanel';

View file

@ -15,7 +15,7 @@ import {
type UserConnection,
type BrowseEntry,
type ApiRequestFunction,
} from '../../../../api/automation2Api';
} from '../../../../api/workflowApi';
import { SharepointBrowseTree } from '../../../FolderTree/SharepointBrowseTree';
import { HybridStaticRefField } from '../shared/HybridStaticRefField';
import {

View file

@ -4,7 +4,7 @@
import React, { useEffect, useState } from 'react';
import type { NodeConfigRendererProps } from './types';
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../../api/automation2Api';
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../../api/workflowApi';
export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
params,

View file

@ -7,7 +7,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import type { NodeConfigRendererProps } from './types';
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../../api/automation2Api';
import { fetchConnections, fetchBrowse, type UserConnection, type BrowseEntry } from '../../../../api/workflowApi';
import { SharepointBrowseTree } from '../../../FolderTree/SharepointBrowseTree';
const browseDetailsStyle: React.CSSProperties = {

View file

@ -5,7 +5,7 @@
import React, { useEffect, useState } from 'react';
import { FaGripVertical, FaTimes } from 'react-icons/fa';
import type { FormField, NodeConfigRendererProps } from '../configs/types';
import { fetchConnections, type UserConnection } from '../../../../api/automation2Api';
import { fetchConnections, type UserConnection } from '../../../../api/workflowApi';
import styles from '../../editor/Automation2FlowEditor.module.css';
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({

View file

@ -3,8 +3,8 @@
*/
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
import type { NodeType } from '../../../../api/automation2Api';
import type { WorkflowEntryPoint } from '../../../../api/automation2Api';
import type { NodeType } from '../../../../api/workflowApi';
import type { WorkflowEntryPoint } from '../../../../api/workflowApi';
import { getLabel } from '../shared/utils';
export const CANVAS_START_NODE_ID = 'start';

View file

@ -8,7 +8,7 @@ import type {
Automation2Graph,
Automation2GraphNode,
Automation2Connection,
} from '../../../../api/automation2Api';
} from '../../../../api/workflowApi';
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
export function fromApiGraph(

View file

@ -2,7 +2,7 @@
* Shared types for node config renderers
*/
import type { ApiRequestFunction } from '../../../../api/automation2Api';
import type { ApiRequestFunction } from '../../../../api/workflowApi';
/** input.form / trigger.form field row. `clickup_tasks` needs connection + list id; value at runtime is `{ add: [taskId], rem: [] }` (ClickUp relationship). */
export type FormField = {

View file

@ -58,6 +58,7 @@
.treeNodeContainer {
display: flex;
flex-direction: column;
position: relative;
}
.treeNode {
@ -266,10 +267,12 @@
align-items: center;
gap: 0.25rem;
flex-shrink: 0;
margin-left: auto;
position: absolute;
right: 0.5rem;
top: 0.25rem;
}
.treeNode:hover .nodeActions {
.treeNodeContainer:hover > .nodeActions {
display: flex;
}

View file

@ -200,7 +200,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
setIsExpanded(!isExpanded);
};
// Render the node content
// Render the node content (actions are rendered outside to avoid button-in-button nesting)
const nodeContent = (
<>
{isExpandable && (
@ -221,11 +221,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
{node.badge}
</span>
)}
{node.actions && (
<span className={styles.nodeActions} onClick={(e) => e.stopPropagation()}>
{node.actions}
</span>
)}
</>
);
@ -262,6 +257,11 @@ const TreeNode: React.FC<TreeNodeProps> = ({
return (
<div className={styles.treeNodeContainer}>
{nodeElement}
{node.actions && (
<span className={styles.nodeActions} onClick={(e) => e.stopPropagation()}>
{node.actions}
</span>
)}
{isExpanded && hasChildren && canRenderChildren && (
<div className={styles.treeNodeChildren}>
{node.children!.map((child, index) => (

View file

@ -19,7 +19,7 @@ import {
FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
FaListAlt, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
FaFileContract,
@ -114,11 +114,10 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'feature.trustee': <FaBriefcase />,
'feature.realestate': <FaBuilding />,
'feature.chatworkflow': <FaPlay />,
'feature.automation': <FaCogs />,
'feature.automation2': <FaProjectDiagram />,
'page.feature.automation2.editor': <FaProjectDiagram />,
'page.feature.automation2.workflows': <FaProjectDiagram />,
'page.feature.automation2.workflows-tasks': <FaClipboardList />,
'feature.graphicalEditor': <FaProjectDiagram />,
'page.feature.graphicalEditor.editor': <FaProjectDiagram />,
'page.feature.graphicalEditor.workflows': <FaProjectDiagram />,
'page.feature.graphicalEditor.workflows-tasks': <FaClipboardList />,
'page.feature.chatbot.conversations': <FaComments />,
'feature.chatbot': <FaComments />,
'feature.teamsbot': <FaHeadset />,

View file

@ -1,618 +0,0 @@
import { useState, useCallback } from 'react';
import { useApiRequest } from './useApi';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
fetchAutomations as fetchAutomationsApi,
fetchAutomation as fetchAutomationApi,
createAutomationApi,
updateAutomationApi,
deleteAutomationApi,
executeAutomationApi,
fetchAutomationTemplates as fetchTemplatesApi,
fetchAutomationTemplateById,
createAutomationTemplateApi,
updateAutomationTemplateApi,
deleteAutomationTemplateApi,
fetchAutomationTemplateAttributes,
fetchWorkflowActions as fetchWorkflowActionsApi,
type Automation,
type AutomationTemplate,
type TextMultilingual,
type WorkflowAction,
type CreateAutomationRequest,
type UpdateAutomationRequest
} from '../api/automationApi';
// Re-export types
export type {
Automation,
AutomationTemplate,
TextMultilingual,
WorkflowAction,
CreateAutomationRequest,
UpdateAutomationRequest
};
// Attribute definition interface
export interface AttributeDefinition {
name: string;
type: 'text' | 'email' | 'date' | 'checkbox' | 'select' | 'multiselect' | 'number' | 'textarea';
label: string;
description?: string;
required?: boolean;
default?: any;
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
validation?: any;
readonly?: boolean;
editable?: boolean;
visible?: boolean;
order?: number;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
filterOptions?: string[];
}
// Automations list hook
export function useAutomations() {
const [automations, setAutomations] = useState<Automation[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [pagination, setPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, Automation[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend - no fallback, errors should be visible
const fetchAttributes = useCallback(async () => {
try {
const response = await api.get('/api/automations/attributes');
let attrs: AttributeDefinition[] = [];
// Backend returns: { attributes: { model: "...", attributes: [...] } }
// So we need to access response.data.attributes.attributes
if (response.data?.attributes?.attributes && Array.isArray(response.data.attributes.attributes)) {
attrs = response.data.attributes.attributes;
} else if (response.data?.attributes && Array.isArray(response.data.attributes)) {
// Fallback: if attributes is directly an array
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
}
if (attrs.length === 0) {
console.warn('No attributes returned from backend for AutomationDefinition');
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
console.error('Error fetching automation attributes:', error);
setAttributes([]);
return [];
}
}, []);
// Fetch permissions from backend
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'data.automation.AutomationDefinition');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching automation permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const fetchAutomations = useCallback(async () => {
try {
const data = await fetchAutomationsApi(request);
// Handle paginated response
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray((data as any).items) ? (data as any).items : [];
setAutomations(items);
if ((data as any).pagination) {
setPagination((data as any).pagination);
}
} else {
const items = Array.isArray(data) ? data : [];
setAutomations(items);
setPagination(null);
}
} catch (error: any) {
setAutomations([]);
setPagination(null);
}
}, [request]);
// Optimistically remove an automation from the local state
const removeOptimistically = (automationId: string) => {
setAutomations(prev => prev.filter(a => a.id !== automationId));
};
// Optimistically update an automation in the local state
const updateOptimistically = (automationId: string, updateData: Partial<Automation>) => {
setAutomations(prev =>
prev.map(a => a.id === automationId ? { ...a, ...updateData } : a)
);
};
// Fetch a single automation by ID
const fetchAutomationById = useCallback(async (automationId: string): Promise<Automation | null> => {
try {
return await fetchAutomationApi(request, automationId);
} catch (error) {
console.error('Error fetching automation by ID:', error);
return null;
}
}, [request]);
// Generate edit fields from attributes dynamically
const generateEditFieldsFromAttributes = useCallback((): Array<{
key: string;
label: string;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly';
editable?: boolean;
required?: boolean;
minRows?: number;
maxRows?: number;
options?: Array<{ value: string | number; label: string }>;
}> => {
if (!attributes || attributes.length === 0) {
return [];
}
// Fields to show in edit form
const editableFields = ['label', 'schedule', 'template', 'placeholders', 'active'];
return attributes
.filter(attr => editableFields.includes(attr.name) && attr.editable !== false)
.map(attr => {
let fieldType: 'string' | 'boolean' | 'textarea' | 'enum' | 'readonly' = 'string';
if (attr.type === 'checkbox') {
fieldType = 'boolean';
} else if (attr.type === 'textarea' || attr.name === 'template' || attr.name === 'placeholders') {
fieldType = 'textarea';
} else if (attr.type === 'select' && attr.options) {
fieldType = 'enum';
}
const field: any = {
key: attr.name,
label: attr.label || attr.name,
type: fieldType,
editable: attr.editable !== false,
required: attr.required || false,
};
if (fieldType === 'textarea') {
field.minRows = 3;
field.maxRows = 15;
}
if (fieldType === 'enum' && attr.options) {
field.options = Array.isArray(attr.options)
? attr.options.map(opt => ({
value: typeof opt === 'object' ? opt.value : opt,
label: typeof opt === 'object'
? (typeof opt.label === 'object' ? opt.label['en'] || opt.label['de'] : opt.label)
: opt
}))
: [];
}
return field;
});
}, [attributes]);
// Generate create fields from attributes
const generateCreateFieldsFromAttributes = useCallback(() => {
return generateEditFieldsFromAttributes();
}, [generateEditFieldsFromAttributes]);
// Ensure attributes are loaded
const ensureAttributesLoaded = useCallback(async () => {
if (attributes.length === 0) {
await fetchAttributes();
}
}, [attributes.length, fetchAttributes]);
// Initial data fetch
const refetch = useCallback(async () => {
await Promise.all([
fetchAutomations(),
fetchAttributes(),
fetchPermissions()
]);
}, [fetchAutomations, fetchAttributes, fetchPermissions]);
return {
automations,
data: automations, // Alias for FormGenerator compatibility
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchAutomationById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
}
// Automation operations hook
export function useAutomationOperations() {
const { request } = useApiRequest();
const [deletingAutomations, setDeletingAutomations] = useState<Set<string>>(new Set());
const [creatingAutomation, setCreatingAutomation] = useState(false);
const [executingAutomations, setExecutingAutomations] = useState<Set<string>>(new Set());
const [deleteError, setDeleteError] = useState<string | null>(null);
const [createError, setCreateError] = useState<string | null>(null);
const [updateError, setUpdateError] = useState<string | null>(null);
// Create a new automation
const handleAutomationCreate = useCallback(async (data: CreateAutomationRequest): Promise<Automation | null> => {
setCreatingAutomation(true);
setCreateError(null);
try {
// Validate required fields - mandateId and featureInstanceId must be provided
if (!data.mandateId || !data.featureInstanceId) {
throw new Error('mandateId and featureInstanceId are required');
}
// Convert placeholders to ensure all values are strings
if (data.placeholders) {
const convertedPlaceholders: Record<string, string> = {};
for (const [key, value] of Object.entries(data.placeholders)) {
if (value === null || value === undefined) {
convertedPlaceholders[key] = '';
} else if (typeof value === 'object') {
convertedPlaceholders[key] = JSON.stringify(value);
} else {
convertedPlaceholders[key] = String(value);
}
}
data.placeholders = convertedPlaceholders;
}
const newAutomation = await createAutomationApi(request, data);
return newAutomation;
} catch (error: any) {
console.error('Error creating automation:', error);
setCreateError(error.message || 'Failed to create automation');
return null;
} finally {
setCreatingAutomation(false);
}
}, [request]);
// Update an existing automation
const handleAutomationUpdate = useCallback(async (
automationId: string,
data: UpdateAutomationRequest
): Promise<boolean> => {
setUpdateError(null);
try {
await updateAutomationApi(request, automationId, data);
return true;
} catch (error: any) {
console.error('Error updating automation:', error);
setUpdateError(error.message || 'Failed to update automation');
return false;
}
}, [request]);
// Delete an automation
const handleAutomationDelete = useCallback(async (automationId: string): Promise<boolean> => {
setDeletingAutomations(prev => new Set(prev).add(automationId));
setDeleteError(null);
try {
await deleteAutomationApi(request, automationId);
return true;
} catch (error: any) {
console.error('Error deleting automation:', error);
setDeleteError(error.message || 'Failed to delete automation');
return false;
} finally {
setDeletingAutomations(prev => {
const newSet = new Set(prev);
newSet.delete(automationId);
return newSet;
});
}
}, [request]);
// Execute an automation
const handleAutomationExecute = useCallback(async (automationId: string): Promise<any> => {
setExecutingAutomations(prev => new Set(prev).add(automationId));
try {
const result = await executeAutomationApi(request, automationId);
return result;
} catch (error: any) {
console.error('Error executing automation:', error);
throw error;
} finally {
setExecutingAutomations(prev => {
const newSet = new Set(prev);
newSet.delete(automationId);
return newSet;
});
}
}, [request]);
// Toggle automation active status
// NOTE: Backend PUT expects full AutomationDefinition object including id
const handleAutomationToggleActive = useCallback(async (
automationId: string,
currentActive: boolean,
fullAutomation?: Automation
): Promise<boolean> => {
try {
// Build full update data - backend expects AutomationDefinition with all fields
const sourceAutomation = fullAutomation || await fetchAutomationApi(request, automationId);
// Backend requires id in body to match URL parameter
const updateData = {
id: automationId, // MUST match URL parameter
mandateId: sourceAutomation.mandateId,
featureInstanceId: sourceAutomation.featureInstanceId,
label: sourceAutomation.label,
schedule: sourceAutomation.schedule,
template: typeof sourceAutomation.template === 'object'
? JSON.stringify(sourceAutomation.template)
: sourceAutomation.template,
placeholders: sourceAutomation.placeholders || {},
active: !currentActive
};
await updateAutomationApi(request, automationId, updateData as any);
return true;
} catch (error: any) {
console.error('Error toggling automation active status:', error);
return false;
}
}, [request]);
// Generic inline update handler for FormGeneratorTable
// NOTE: Backend PUT requires full object, so we merge changes with existing row data
const handleInlineUpdate = useCallback(async (
automationId: string,
changes: Partial<Automation>,
existingRow?: Automation
) => {
if (!existingRow) {
throw new Error('Existing row data required for inline update');
}
try {
// Merge changes with existing row data and send all required fields
const updateData = {
id: automationId, // MUST match URL parameter
mandateId: existingRow.mandateId,
featureInstanceId: existingRow.featureInstanceId,
label: existingRow.label,
schedule: existingRow.schedule,
template: typeof existingRow.template === 'object'
? JSON.stringify(existingRow.template)
: existingRow.template,
placeholders: existingRow.placeholders || {},
// Apply the changes (e.g., active: true/false)
...changes
};
await updateAutomationApi(request, automationId, updateData as any);
return { success: true };
} catch (error: any) {
console.error('Error in inline update:', error);
throw new Error(error.message || 'Failed to update');
}
}, [request]);
// Fetch templates
const fetchTemplates = useCallback(async (): Promise<AutomationTemplate[]> => {
try {
return await fetchTemplatesApi(request);
} catch (error: any) {
console.error('Error fetching templates:', error);
return [];
}
}, [request]);
return {
handleAutomationCreate,
handleAutomationUpdate,
handleAutomationDelete,
handleAutomationExecute,
handleAutomationToggleActive,
handleInlineUpdate,
fetchTemplates,
deletingAutomations,
creatingAutomation,
executingAutomations,
deleteError,
createError,
updateError
};
}
// ============================================================================
// AUTOMATION TEMPLATES (DB) HOOK
// ============================================================================
/**
* Hook for managing AutomationTemplates from database
*/
export function useAutomationTemplates() {
const [templates, setTemplates] = useState<AutomationTemplate[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [pagination, setPagination] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { request } = useApiRequest();
const { checkPermission } = usePermissions();
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const fetchTemplates = useCallback(async (params?: any) => {
setLoading(true);
setError(null);
try {
const data = await fetchTemplatesApi(request, params);
if (data && typeof data === 'object' && 'items' in data) {
setTemplates(Array.isArray(data.items) ? data.items : []);
if (data.pagination) setPagination(data.pagination);
} else {
setTemplates(Array.isArray(data) ? data : []);
setPagination(null);
}
} catch (e: any) {
console.error('Error fetching templates:', e);
setError(e.message || 'Failed to fetch templates');
setTemplates([]);
setPagination(null);
} finally {
setLoading(false);
}
}, [request]);
const fetchAttributes = useCallback(async () => {
try {
const attrs = await fetchAutomationTemplateAttributes(request);
setAttributes(attrs);
return attrs;
} catch (e: any) {
console.error('Error fetching template attributes:', e);
setAttributes([]);
return [];
}
}, [request]);
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'data.automation.AutomationTemplate');
setPermissions(perms);
return perms;
} catch (e: any) {
console.error('Error fetching template permissions:', e);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
const getTemplate = useCallback(async (templateId: string) => {
return await fetchAutomationTemplateById(request, templateId);
}, [request]);
const createTemplate = useCallback(async (data: Omit<AutomationTemplate, 'id' | 'sysCreatedAt' | 'sysCreatedBy'>) => {
return await createAutomationTemplateApi(request, data);
}, [request]);
const updateTemplate = useCallback(async (templateId: string, data: Partial<AutomationTemplate>) => {
return await updateAutomationTemplateApi(request, templateId, data);
}, [request]);
const deleteTemplate = useCallback(async (templateId: string) => {
await deleteAutomationTemplateApi(request, templateId);
}, [request]);
const duplicateTemplate = useCallback(async (templateId: string) => {
const response = await request({ url: `/api/automation-templates/${templateId}/duplicate`, method: 'post' });
return response;
}, [request]);
const refetch = useCallback(async () => {
await Promise.all([
fetchTemplates(),
fetchAttributes(),
fetchPermissions()
]);
}, [fetchTemplates, fetchAttributes, fetchPermissions]);
return {
templates,
data: templates,
attributes,
loading,
error,
permissions,
pagination,
refetch,
fetchTemplates,
fetchAttributes,
fetchPermissions,
getTemplate,
createTemplate,
updateTemplate,
deleteTemplate,
duplicateTemplate,
};
}
// ============================================================================
// WORKFLOW ACTIONS HOOK
// ============================================================================
/**
* Hook for fetching available workflow actions (for Actions panel)
*/
export function useWorkflowActions() {
const [actions, setActions] = useState<WorkflowAction[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { request } = useApiRequest();
const fetchActions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchWorkflowActionsApi(request);
setActions(data);
} catch (e: any) {
console.error('Error fetching workflow actions:', e);
setError(e.message || 'Failed to fetch actions');
setActions([]);
} finally {
setLoading(false);
}
}, [request]);
return {
actions,
loading,
error,
fetchActions
};
}

View file

@ -12,10 +12,12 @@ import { MandateNavigation } from '../components/Navigation/MandateNavigation';
import { UserSection } from '../components/Navigation/UserSection';
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
import styles from './MainLayout.module.css';
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
// =============================================================================
// INNER LAYOUT (mit Zugriff auf Store)
@ -27,7 +29,8 @@ const MainLayoutInner: React.FC = () => {
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname);
const isCommcoachKeepAliveVisible = _COMMCOACH_ROUTE_RE.test(location.pathname);
const hideOutletShell = isWorkspaceKeepAliveVisible || isCommcoachKeepAliveVisible;
const isGEEditorKeepAliveVisible = _GE_EDITOR_ROUTE_RE.test(location.pathname);
const hideOutletShell = isWorkspaceKeepAliveVisible || isCommcoachKeepAliveVisible || isGEEditorKeepAliveVisible;
// Features laden beim Mount
useEffect(() => {
@ -112,6 +115,7 @@ const MainLayoutInner: React.FC = () => {
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
<GraphicalEditorKeepAlive isVisible={isGEEditorKeepAliveVisible} />
<div
className={styles.outletShell}

View file

@ -6,7 +6,7 @@
*/
import React from 'react';
import { Navigate, useParams } from 'react-router-dom';
import { useParams } from 'react-router-dom';
import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
import { useLanguage } from '../providers/language/LanguageContext';
@ -28,13 +28,12 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi
// RealEstate Views
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
// Automation Views
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';
// GraphicalEditor Views
import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage';
import { GraphicalEditorWorkflowsPage } from './views/graphicalEditor/GraphicalEditorWorkflowsPage';
import { GraphicalEditorWorkflowsTasksPage } from './views/graphicalEditor/GraphicalEditorWorkflowsTasksPage';
import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage';
import { GraphicalEditorDashboardPage } from './views/graphicalEditor/GraphicalEditorDashboardPage';
// Workspace Views
import { WorkspacePage } from './views/workspace/WorkspacePage';
@ -130,14 +129,12 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
dashboard: RealEstatePekView,
'instance-roles': RealEstateInstanceRolesPlaceholder,
},
automation: {
definitions: AutomationDefinitionsView,
templates: AutomationTemplatesView,
},
automation2: {
editor: Automation2Page,
workflows: Automation2WorkflowsPage,
'workflows-tasks': Automation2WorkflowsTasksPage,
graphicalEditor: {
editor: GraphicalEditorPage,
workflows: GraphicalEditorWorkflowsPage,
'workflows-tasks': GraphicalEditorWorkflowsTasksPage,
templates: GraphicalEditorTemplatesPage,
dashboard: GraphicalEditorDashboardPage,
},
workspace: {
dashboard: WorkspacePage,
@ -175,11 +172,6 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
const { currentLanguage } = useLanguage();
const { mandateId, instanceId } = useParams<{ mandateId?: string; instanceId?: string }>();
// automation2: Dashboard entfernt → Index/Base-URL auf Editor umleiten
if (featureCode === 'automation2' && view === 'dashboard' && mandateId && instanceId) {
return <Navigate to={`/mandates/${mandateId}/automation2/${instanceId}/editor`} replace />;
}
// Berechtigungs-Check
const viewCode = `${featureCode}-${view}`;
const canView = useCanViewFeatureView(viewCode);
@ -224,6 +216,11 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return null;
}
// GraphicalEditor editor is rendered persistently by GraphicalEditorKeepAlive at MainLayout level.
if (featureCode === 'graphicalEditor' && view === 'editor') {
return null;
}
// View-Komponente finden
const featureViews = VIEW_COMPONENTS[featureCode];
if (!featureViews) {

View file

@ -14,7 +14,7 @@ import styles from './Store.module.css';
const FEATURE_ICONS: Record<string, React.ReactNode> = {
automation: <FaCogs />,
automation2: <FaProjectDiagram />,
graphicalEditor: <FaProjectDiagram />,
teamsbot: <FaHeadset />,
workspace: <FaComments />,
commcoach: <FaComments />,
@ -26,7 +26,7 @@ const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = {
en: 'Create and manage automations to handle recurring tasks efficiently.',
fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.',
},
automation2: {
graphicalEditor: {
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.',

View file

@ -1,243 +0,0 @@
/**
* AdminAutomationEventsPage
*
* Admin page for viewing and managing automation scheduler events.
* SysAdmin-only: displays all automation definitions with scheduler status.
* Uses FormGeneratorTable for consistent look with other admin pages.
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { FaSync } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
interface AutomationEvent {
eventId: string;
automationId: string;
name: string;
nextRunTime: string | null;
trigger: string | null;
createdBy: string;
mandate: string;
featureInstance: string;
}
const _formatNextRun = (nextRunTime: string | null): string => {
if (!nextRunTime || nextRunTime === 'None') return '';
try {
const date = new Date(nextRunTime);
return date.toLocaleString('de-CH', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return nextRunTime;
}
};
export const AdminAutomationEventsPage: React.FC = () => {
const [events, setEvents] = useState<AutomationEvent[]>([]);
const [pagination, setPagination] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [syncResult, setSyncResult] = useState<string | null>(null);
const _fetchEvents = useCallback(async (params?: any) => {
try {
setLoading(true);
setError(null);
const requestParams: Record<string, string> = {};
if (params && typeof params === 'object') {
const paginationObj: any = {};
if (params.page !== undefined) paginationObj.page = params.page;
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
}
}
const response = await api.get('/api/admin/automation-events', { params: requestParams });
const data = response.data;
if (data && typeof data === 'object' && 'items' in data) {
setEvents((data.items || []).map((e: any) => ({ ...e, id: e.eventId })));
if (data.pagination) setPagination(data.pagination);
} else {
setEvents((Array.isArray(data) ? data : []).map((e: any) => ({ ...e, id: e.eventId })));
setPagination(null);
}
} catch (err: any) {
setError(err.response?.data?.detail || 'Fehler beim Laden der Events');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
_fetchEvents();
}, [_fetchEvents]);
const _handleSync = async () => {
try {
setSyncing(true);
setError(null);
setSyncResult(null);
const response = await api.post('/api/admin/automation-events/sync');
const data = response.data;
setSyncResult(`Sync erfolgreich: ${data.synced} Automationen synchronisiert`);
await _fetchEvents();
} catch (err: any) {
setError(err.response?.data?.detail || 'Fehler beim Synchronisieren');
} finally {
setSyncing(false);
}
};
const _handleDelete = useCallback(async (eventId: string) => {
try {
setError(null);
const encodedId = encodeURIComponent(eventId);
await api.post(`/api/admin/automation-events/${encodedId}/remove`);
setEvents(prev => prev.filter(e => e.eventId !== eventId));
} catch (err: any) {
setError(err.response?.data?.detail || 'Fehler beim Entfernen des Events');
throw err;
}
}, [events]);
const columns: ColumnConfig[] = useMemo(() => [
{
key: 'name',
label: 'Name',
type: 'string' as const,
sortable: true,
searchable: true,
width: 200,
minWidth: 120,
},
{
key: 'mandate',
label: 'Mandant',
type: 'string' as const,
sortable: true,
filterable: true,
width: 150,
minWidth: 100,
},
{
key: 'createdBy',
label: 'Erstellt von',
type: 'string' as const,
sortable: true,
filterable: true,
width: 130,
minWidth: 80,
},
{
key: 'featureInstance',
label: 'Feature',
type: 'string' as const,
sortable: true,
filterable: true,
width: 130,
minWidth: 80,
},
{
key: 'nextRunTime',
label: 'Nächste Ausführung',
type: 'string' as const,
sortable: true,
width: 170,
minWidth: 130,
formatter: (value: any) => {
const formatted = _formatNextRun(value);
if (!formatted) return <span style={{ color: 'var(--text-tertiary, #999)' }}></span>;
return formatted;
},
},
{
key: 'trigger',
label: 'Trigger',
type: 'string' as const,
sortable: false,
width: 160,
minWidth: 100,
},
], []);
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Automation Events</h1>
<p className={styles.pageSubtitle}>
Aktive Scheduler-Jobs ({events.length} Events)
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={_fetchEvents}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
<button
className={styles.primaryButton}
onClick={_handleSync}
disabled={syncing}
>
<FaSync className={syncing ? 'spinning' : ''} /> Sync All
</button>
</div>
</div>
{syncResult && (
<div className={styles.infoBox} style={{ background: 'var(--success-bg, #f0fff4)', borderColor: 'var(--success-color, #38a169)' }}>
<span style={{ marginRight: 8, color: 'var(--success-color, #38a169)' }}>&#10003;</span>
{syncResult}
</div>
)}
{error && (
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<span style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }}>!</span>
{error}
</div>
)}
<FormGeneratorTable
data={events}
columns={columns}
apiEndpoint="/api/admin/automation-events"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
{
type: 'delete' as const,
title: 'Event entfernen',
},
]}
hookData={{
handleDelete: _handleDelete,
refetch: _fetchEvents,
pagination,
}}
emptyMessage="Keine Automationen gefunden. Nutzen Sie 'Sync All', um Automationen zu synchronisieren."
/>
</div>
);
};
export default AdminAutomationEventsPage;

View file

@ -1,223 +0,0 @@
/**
* AdminAutomationLogsPage
*
* SysAdmin-only page for viewing consolidated automation execution logs
* across all mandates and feature instances.
* Uses FormGeneratorTable with backend-driven pagination.
*/
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { FaSync, FaCheck, FaExclamationCircle, FaTimes } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
import { FormGeneratorTable, type ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
interface AutomationLogEntry {
id: string;
timestamp: number;
automationId: string;
automationLabel: string;
mandateName: string;
featureInstanceName: string;
executedBy: string;
status: string;
workflowId: string;
messages: string;
}
const _formatTimestamp = (ts: unknown): React.ReactNode => {
if (!ts || typeof ts !== 'number') return <span style={{ color: 'var(--text-tertiary, #999)' }}></span>;
return new Date(ts * 1000).toLocaleString('de-CH', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit', second: '2-digit',
});
};
const _formatStatus = (value: unknown): React.ReactNode => {
const status = String(value || '');
const map: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
completed: { icon: <FaCheck style={{ marginRight: 4 }} />, color: 'var(--success-color, #16a34a)', label: 'Abgeschlossen' },
error: { icon: <FaExclamationCircle style={{ marginRight: 4 }} />, color: 'var(--error-color, #dc2626)', label: 'Fehler' },
failed: { icon: <FaExclamationCircle style={{ marginRight: 4 }} />, color: 'var(--error-color, #dc2626)', label: 'Fehlgeschlagen' },
stopped: { icon: <FaTimes style={{ marginRight: 4 }} />, color: 'var(--warning-color, #d97706)', label: 'Gestoppt' },
};
const entry = map[status];
if (!entry) return status || '';
return (
<span style={{ display: 'inline-flex', alignItems: 'center', color: entry.color, fontWeight: 500 }}>
{entry.icon}{entry.label}
</span>
);
};
export const AdminAutomationLogsPage: React.FC = () => {
const [logs, setLogs] = useState<AutomationLogEntry[]>([]);
const [pagination, setPagination] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const _fetchLogs = useCallback(async (params?: any) => {
try {
setLoading(true);
setError(null);
const requestParams: Record<string, string> = {};
if (params && typeof params === 'object') {
const paginationObj: any = {};
if (params.page !== undefined) paginationObj.page = params.page;
if (params.pageSize !== undefined) paginationObj.pageSize = params.pageSize;
if (params.sort) paginationObj.sort = params.sort;
if (params.filters) paginationObj.filters = params.filters;
if (params.search) paginationObj.search = params.search;
if (Object.keys(paginationObj).length > 0) {
requestParams.pagination = JSON.stringify(paginationObj);
}
}
const response = await api.get('/api/admin/automation-logs', { params: requestParams });
const data = response.data;
if (data && typeof data === 'object' && 'items' in data) {
setLogs(data.items || []);
if (data.pagination) setPagination(data.pagination);
} else {
setLogs(Array.isArray(data) ? data : []);
setPagination(null);
}
} catch (err: any) {
setError(err.response?.data?.detail || 'Fehler beim Laden der Ausführungsprotokolle');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { _fetchLogs(); }, [_fetchLogs]);
const columns: ColumnConfig[] = useMemo(() => [
{
key: 'timestamp',
label: 'Zeitpunkt',
type: 'number' as const,
sortable: true,
filterable: false,
width: 170,
minWidth: 140,
formatter: _formatTimestamp,
},
{
key: 'automationLabel',
label: 'Automatisierung',
type: 'string' as const,
sortable: true,
filterable: true,
searchable: true,
width: 200,
minWidth: 130,
},
{
key: 'mandateName',
label: 'Mandant',
type: 'string' as const,
sortable: true,
filterable: true,
width: 150,
minWidth: 100,
},
{
key: 'featureInstanceName',
label: 'Feature-Instanz',
type: 'string' as const,
sortable: true,
filterable: true,
width: 150,
minWidth: 100,
},
{
key: 'executedBy',
label: 'Ausgeführt von',
type: 'string' as const,
sortable: true,
filterable: true,
width: 140,
minWidth: 100,
},
{
key: 'status',
label: 'Status',
type: 'string' as const,
sortable: true,
filterable: true,
width: 140,
minWidth: 100,
formatter: _formatStatus,
},
{
key: 'workflowId',
label: 'Workflow-ID',
type: 'string' as const,
sortable: false,
filterable: false,
width: 120,
minWidth: 80,
formatter: (v: unknown) =>
v ? <code style={{ fontSize: '0.8em', color: 'var(--text-secondary)' }}>{String(v).slice(0, 8)}</code> : '',
},
{
key: 'messages',
label: 'Meldungen',
type: 'string' as const,
sortable: false,
filterable: false,
searchable: true,
width: 300,
minWidth: 150,
maxWidth: 500,
},
], []);
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Ausführungsprotokolle</h1>
<p className={styles.pageSubtitle}>
Konsolidierte Automation-Logs über alle Mandanten
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => _fetchLogs()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
</div>
</div>
{error && (
<div className={styles.infoBox} style={{ background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<span style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }}>!</span>
{error}
</div>
)}
<FormGeneratorTable
data={logs}
columns={columns}
apiEndpoint="/api/admin/automation-logs"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
hookData={{
refetch: _fetchLogs,
pagination,
}}
emptyMessage="Keine Ausführungsprotokolle vorhanden"
/>
</div>
);
};
export default AdminAutomationLogsPage;

View file

@ -434,27 +434,6 @@ export const AdminFeatureAccessPage: React.FC = () => {
Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu verwalten.
</p>
</div>
) : loading && instances.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Feature-Instanzen...</span>
</div>
) : instances.length === 0 ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Feature-Instanzen</h3>
<p className={styles.emptyDescription}>
Für diesen Mandanten wurden noch keine Feature-Instanzen erstellt.
</p>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
disabled={features.length === 0}
title={features.length === 0 ? 'Keine Features verfügbar. Bitte laden Sie die Features erneut.' : undefined}
>
<FaPlus /> Erste Instanz erstellen
</button>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable

View file

@ -511,26 +511,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
: 'Wählen Sie eine Feature-Instanz aus, um deren Benutzer zu verwalten.'}
</p>
</div>
) : usersLoading && instanceUsers.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Benutzer...</span>
</div>
) : instanceUsers.length === 0 ? (
<div className={styles.emptyState}>
<FaUsers className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Benutzer</h3>
<p className={styles.emptyDescription}>
Dieser Feature-Instanz sind noch keine Benutzer zugewiesen.
</p>
<button
className={styles.primaryButton}
onClick={() => setShowAddModal(true)}
disabled={availableUsers.length === 0 || instanceRoles.length === 0}
>
<FaPlus /> Ersten Benutzer hinzufügen
</button>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable

View file

@ -348,25 +348,6 @@ export const AdminFeatureRolesPage: React.FC = () => {
Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.
</p>
</div>
) : loading && roles.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Feature-Rollen...</span>
</div>
) : roles.length === 0 ? (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Rollen</h3>
<p className={styles.emptyDescription}>
Es gibt noch keine Template-Rollen für dieses Feature.
</p>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Erste Rolle erstellen
</button>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable

View file

@ -331,25 +331,6 @@ export const AdminInvitationsPage: React.FC = () => {
Wählen Sie einen Mandanten aus, um dessen Einladungen zu verwalten.
</p>
</div>
) : loading && invitations.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Einladungen...</span>
</div>
) : invitations.length === 0 ? (
<div className={styles.emptyState}>
<FaEnvelopeOpenText className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Einladungen</h3>
<p className={styles.emptyDescription}>
Es gibt noch keine aktiven Einladungen für diesen Mandanten.
</p>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Erste Einladung erstellen
</button>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable

View file

@ -406,29 +406,6 @@ export const AdminMandateRolesPage: React.FC = () => {
Wählen Sie einen Mandanten aus, um dessen Rollen zu verwalten.
</p>
</div>
) : loading && roles.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Rollen...</span>
</div>
) : roles.length === 0 ? (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Rollen</h3>
<p className={styles.emptyDescription}>
{scopeFilter === 'mandate'
? 'Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.'
: scopeFilter === 'global'
? 'Es gibt noch keine Rollen-Templates.'
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
</p>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Erste Rolle erstellen
</button>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable

View file

@ -198,29 +198,7 @@ export const AdminMandatesPage: React.FC = () => {
</div>
<div className={styles.tableContainer}>
{loading && mandates.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Mandanten...</span>
</div>
) : mandates.length === 0 ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Mandanten vorhanden</h3>
<p className={styles.emptyDescription}>
Erstellen Sie einen neuen Mandanten, um loszulegen.
</p>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Ersten Mandanten erstellen
</button>
)}
</div>
) : (
<FormGeneratorTable
<FormGeneratorTable
data={mandates}
columns={columns}
apiEndpoint="/api/mandates/"
@ -265,7 +243,6 @@ export const AdminMandatesPage: React.FC = () => {
}}
emptyMessage="Keine Mandanten gefunden"
/>
)}
</div>
{/* Create Modal */}

View file

@ -328,26 +328,6 @@ export const AdminUserMandatesPage: React.FC = () => {
Wählen Sie einen Mandanten aus, um dessen Mitglieder zu verwalten.
</p>
</div>
) : loading && users.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Mandanten-Mitglieder...</span>
</div>
) : users.length === 0 ? (
<div className={styles.emptyState}>
<FaUsers className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Mitglieder</h3>
<p className={styles.emptyDescription}>
Diesem Mandanten sind noch keine Benutzer zugewiesen.
</p>
<button
className={styles.primaryButton}
onClick={() => setShowAddModal(true)}
disabled={availableUsers.length === 0}
>
<FaPlus /> Ersten Benutzer hinzufügen
</button>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable

View file

@ -178,29 +178,7 @@ export const AdminUsersPage: React.FC = () => {
</div>
<div className={styles.tableContainer}>
{loading && (!users || users.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Benutzer...</span>
</div>
) : !users || users.length === 0 ? (
<div className={styles.emptyState}>
<FaUsers className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Benutzer vorhanden</h3>
<p className={styles.emptyDescription}>
Erstellen Sie einen neuen Benutzer, um loszulegen.
</p>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Ersten Benutzer erstellen
</button>
)}
</div>
) : (
<FormGeneratorTable
<FormGeneratorTable
data={users}
columns={columns}
apiEndpoint="/api/users/"
@ -242,7 +220,6 @@ export const AdminUsersPage: React.FC = () => {
}}
emptyMessage="Keine Benutzer gefunden"
/>
)}
</div>
{/* Create Modal */}

View file

@ -15,6 +15,4 @@ export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
export { AdminAutomationEventsPage } from './AdminAutomationEventsPage';
export { AdminAutomationLogsPage } from './AdminAutomationLogsPage';
export { AdminLogsPage } from './AdminLogsPage';

View file

@ -302,51 +302,7 @@ export const ConnectionsPage: React.FC = () => {
</div>
<div className={styles.tableContainer}>
{loading && (!connections || connections.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Verbindungen...</span>
</div>
) : !connections || connections.length === 0 ? (
<div className={styles.emptyState}>
<FaPlug className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Verbindungen vorhanden</h3>
<p className={styles.emptyDescription}>
{isClickupConnectionUiEnabled
? 'Verbinden Sie Ihr Google-, Microsoft- oder ClickUp-Konto, um loszulegen.'
: 'Verbinden Sie Ihr Google- oder Microsoft-Konto, um loszulegen.'}
</p>
{canCreate && (
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
<button
className={styles.googleButton}
onClick={handleCreateGoogle}
disabled={isConnecting}
>
<FaGoogle /> Mit Google verbinden
</button>
<button
className={styles.primaryButton}
onClick={handleCreateMicrosoft}
disabled={isConnecting}
>
<FaMicrosoft /> Mit Microsoft verbinden
</button>
{isClickupConnectionUiEnabled && (
<button
type="button"
className={styles.clickupButton}
onClick={handleCreateClickup}
disabled={isConnecting}
>
<FaTasks /> Mit ClickUp verbinden
</button>
)}
</div>
)}
</div>
) : (
<FormGeneratorTable
<FormGeneratorTable
data={connections}
columns={columns}
apiEndpoint="/api/connections/"
@ -400,7 +356,6 @@ export const ConnectionsPage: React.FC = () => {
}}
emptyMessage="Keine Verbindungen gefunden"
/>
)}
</div>
{/* Edit Modal */}

View file

@ -410,30 +410,7 @@ export const FilesPage: React.FC = () => {
{/* Table content */}
<div style={{ flex: 1, overflow: 'auto' }}>
{loading && (!files || files.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Dateien...</span>
</div>
) : filteredFiles.length === 0 ? (
<div className={styles.emptyState}>
<FaFolder className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>
{selectedFolderId ? 'Ordner ist leer' : 'Keine Dateien vorhanden'}
</h3>
<p className={styles.emptyDescription}>
{selectedFolderId
? 'Verschieben Sie Dateien hierher oder laden Sie neue hoch.'
: 'Laden Sie eine Datei hoch, um loszulegen.'}
</p>
{canCreate && (
<button className={styles.primaryButton} onClick={handleUploadClick} disabled={uploadingFile}>
<FaUpload /> Datei hochladen
</button>
)}
</div>
) : (
<FormGeneratorTable
<FormGeneratorTable
data={filteredFiles}
columns={columns}
apiEndpoint="/api/files/list"
@ -490,7 +467,6 @@ export const FilesPage: React.FC = () => {
}}
emptyMessage="Keine Dateien gefunden"
/>
)}
</div>
</div>
</div>

View file

@ -1,623 +0,0 @@
/**
* AutomationDefinitionsView
*
* View for viewing and managing workflow automation definitions.
* Includes template selection, execution modal with live logs, and execution history.
*/
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../../hooks/useAutomations';
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
import { AutomationEditor } from '../../../components/AutomationEditor';
import { FaSync, FaRobot, FaRocket, FaPlus, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner } from 'react-icons/fa';
import { useToast } from '../../../contexts/ToastContext';
import { useApiRequest } from '../../../hooks/useApi';
import { useFeatureStore } from '../../../stores/featureStore';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import styles from '../../admin/Admin.module.css';
interface WorkflowLog {
id: string;
timestamp: number;
message: string;
status?: string;
progress?: number;
}
export const AutomationDefinitionsView: React.FC = () => {
const { instanceId: routeInstanceId, featureCode: routeFeatureCode } = useCurrentInstance();
const { getAllInstances } = useFeatureStore();
const instances = getAllInstances();
const chatbotInstance = instances.find(i => i.featureCode === 'chatbot') || instances[0];
const automationInstance = instances.find(i => i.featureCode === 'automation');
// When under automation feature route, use route context; otherwise use featureStore
const mandateId = routeFeatureCode === 'automation' && routeInstanceId
? (automationInstance?.mandateId ?? chatbotInstance?.mandateId)
: chatbotInstance?.mandateId;
const featureInstanceId = routeFeatureCode === 'automation' && routeInstanceId
? routeInstanceId
: (chatbotInstance?.id ?? automationInstance?.id);
const automationWorkflowInstanceId = routeFeatureCode === 'automation' ? routeInstanceId : undefined;
const {
data: automations,
attributes,
permissions,
pagination,
loading,
error,
refetch,
fetchAutomationById,
updateOptimistically,
} = useAutomations();
const {
handleAutomationCreate,
handleAutomationUpdate,
handleAutomationDelete,
handleAutomationExecute,
handleInlineUpdate,
fetchTemplates,
deletingAutomations,
executingAutomations,
} = useAutomationOperations();
const { showSuccess, showError, showInfo } = useToast();
const { request } = useApiRequest();
const [showEditor, setShowEditor] = useState(false);
const [editingAutomation, setEditingAutomation] = useState<Automation | null>(null);
const [editorSaving, setEditorSaving] = useState(false);
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [templates, setTemplates] = useState<AutomationTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [executionModal, setExecutionModal] = useState<{
visible: boolean;
automationId: string | null;
automationLabel: string;
featureInstanceId: string | null;
workflowId: string | null;
status: 'starting' | 'running' | 'completed' | 'stopped' | 'error';
logs: WorkflowLog[];
}>({
visible: false,
automationId: null,
automationLabel: '',
featureInstanceId: null,
workflowId: null,
status: 'starting',
logs: [],
});
const [logsModal, setLogsModal] = useState<{
visible: boolean;
automation: Automation | null;
}>({
visible: false,
automation: null,
});
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const lastLogIdRef = useRef<string | null>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => { refetch(); }, []);
useEffect(() => () => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
}, []);
useEffect(() => {
if (logContainerRef.current) logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}, [executionModal.logs]);
const columns = useMemo(() => {
const hiddenColumns = [
'id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt',
'template', 'executionLogs', 'placeholders',
'sysCreatedByUserName', 'mandateName', 'featureInstanceName',
];
const attrColumns = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => ({
key: attr.name,
label: attr.label || attr.name,
type: attr.type as any,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
}));
const enrichedColumns = [
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
{ key: 'featureInstanceName', label: 'Feature-Instanz', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 160, minWidth: 100, maxWidth: 250 },
{ key: 'sysCreatedByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
];
return [...attrColumns, ...enrichedColumns];
}, [attributes]);
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
const handleEditClick = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
setEditingAutomation(fullAutomation as Automation || automation);
setShowEditor(true);
};
const handleCreateClick = () => {
setEditingAutomation({
mandateId: mandateId!,
featureInstanceId: featureInstanceId!,
label: '',
schedule: '0 22 * * *',
active: false,
placeholders: {},
} as Automation);
setShowEditor(true);
};
const handleEditorSave = async (data: Partial<Automation>) => {
if (!mandateId || !featureInstanceId) {
showError('Fehler: Kein aktiver Mandant oder Feature-Instanz gefunden');
return;
}
setEditorSaving(true);
try {
const saveData = { ...data, mandateId, featureInstanceId };
if (editingAutomation?.id) {
saveData.id = editingAutomation.id;
const success = await handleAutomationUpdate(editingAutomation.id, saveData as any);
if (success) {
showSuccess('Automatisierung aktualisiert');
setShowEditor(false);
setEditingAutomation(null);
await refetch();
}
} else {
const result = await handleAutomationCreate(saveData as any);
if (result) {
showSuccess('Automatisierung erstellt');
setShowEditor(false);
setEditingAutomation(null);
await refetch();
}
}
} catch (err: any) {
showError(`Fehler: ${err.message}`);
} finally {
setEditorSaving(false);
}
};
const handleEditorCancel = () => {
setShowEditor(false);
setEditingAutomation(null);
};
const handleDelete = async (automation: Automation) => {
const success = await handleAutomationDelete(automation.id);
if (success) {
showSuccess('Automatisierung gelöscht');
await refetch();
}
};
const handleDuplicate = async (automation: Automation) => {
try {
await request({ url: `/api/automations/${automation.id}/duplicate`, method: 'post' });
showSuccess('Automatisierung dupliziert');
await refetch();
} catch (err: any) {
showError(`Fehler beim Duplizieren: ${err.message}`);
}
};
const handleLoadTemplates = async () => {
setLoadingTemplates(true);
try {
const loadedTemplates = await fetchTemplates();
setTemplates(loadedTemplates);
if (loadedTemplates.length === 0) {
showInfo('Keine Vorlagen verfügbar');
} else {
setShowTemplateModal(true);
}
} catch (err) {
showError('Fehler beim Laden der Vorlagen');
} finally {
setLoadingTemplates(false);
}
};
const handleTemplateSelect = (template: AutomationTemplate) => {
setShowTemplateModal(false);
if (!mandateId || !featureInstanceId) {
showError('Fehler: Kein aktiver Mandant oder Feature-Instanz gefunden');
return;
}
let templateLabel = 'Neue Automatisierung';
if (template.label) {
templateLabel = typeof template.label === 'string'
? template.label
: (template.label as any).de || (template.label as any).en || 'Neue Automatisierung';
} else if (template.overview) {
templateLabel = typeof template.overview === 'string'
? template.overview
: ((template.overview as any).de || (template.overview as any).en || 'Neue Automatisierung');
}
const convertedPlaceholders: Record<string, string> = {};
const templateParams = (template as any).parameters || {};
for (const [key, value] of Object.entries(templateParams)) {
if (value === null || value === undefined) {
convertedPlaceholders[key] = '';
} else if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
convertedPlaceholders[key] = JSON.stringify(value);
} else {
convertedPlaceholders[key] = String(value);
}
}
const prefillData: Partial<Automation> = {
mandateId,
featureInstanceId,
label: templateLabel,
template: typeof template.template === 'string' ? template.template : JSON.stringify(template.template, null, 2),
placeholders: convertedPlaceholders,
active: false,
schedule: '0 22 * * *',
};
setEditingAutomation(prefillData as Automation);
setShowEditor(true);
};
const pollWorkflowLogs = useCallback(async (workflowId: string, instanceId: string) => {
try {
const contextHeaders: Record<string, string> = {};
if (mandateId) contextHeaders['X-Mandate-Id'] = mandateId;
const logsUrl = `/api/automations/${instanceId}/workflows/${workflowId}/logs`;
const workflowUrl = `/api/automations/${instanceId}/workflows/${workflowId}`;
const response = await request({
url: logsUrl,
method: 'get',
params: lastLogIdRef.current ? { logId: lastLogIdRef.current } : {},
additionalConfig: { headers: contextHeaders },
});
const logs: WorkflowLog[] = response?.items || response || [];
if (logs.length > 0) {
setExecutionModal(prev => {
const existingIds = new Set(prev.logs.map(l => l.id));
const newLogs = logs.filter(l => !existingIds.has(l.id));
return { ...prev, logs: [...prev.logs, ...newLogs] };
});
lastLogIdRef.current = logs[logs.length - 1].id;
}
const statusResponse = await request({
url: workflowUrl,
method: 'get',
additionalConfig: { headers: contextHeaders },
});
const workflowStatus = statusResponse?.status;
if (workflowStatus === 'completed' || workflowStatus === 'stopped' || workflowStatus === 'error' || workflowStatus === 'failed') {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setExecutionModal(prev => ({
...prev,
status: workflowStatus === 'completed' ? 'completed' : workflowStatus === 'error' || workflowStatus === 'failed' ? 'error' : 'stopped',
}));
if (workflowStatus === 'completed') showSuccess('Automatisierung erfolgreich abgeschlossen');
else if (workflowStatus === 'error' || workflowStatus === 'failed') showError('Automatisierung fehlgeschlagen');
else showInfo('Automatisierung gestoppt');
refetch();
}
} catch (err) {
console.error('Error polling workflow logs:', err);
}
}, [request, refetch, showSuccess, showError, showInfo, mandateId]);
const handleExecute = async (automation: Automation) => {
lastLogIdRef.current = null;
setExecutionModal({
visible: true,
automationId: automation.id,
automationLabel: automation.label,
featureInstanceId: automation.featureInstanceId ?? automationWorkflowInstanceId ?? null,
workflowId: null,
status: 'starting',
logs: [{ id: 'init', timestamp: Date.now() / 1000, message: 'Automatisierung wird gestartet...' }],
});
try {
const result = await handleAutomationExecute(automation.id);
const workflowId = result?.id;
const instanceId = automation.featureInstanceId ?? automationWorkflowInstanceId;
if (workflowId && instanceId) {
setExecutionModal(prev => ({
...prev,
workflowId,
status: 'running',
logs: [...prev.logs, { id: 'started', timestamp: Date.now() / 1000, message: `Workflow ${workflowId} gestartet`, status: 'running' }],
}));
pollIntervalRef.current = setInterval(() => pollWorkflowLogs(workflowId, instanceId), 2000);
} else if (workflowId && !instanceId) {
setExecutionModal(prev => ({ ...prev, status: 'error', logs: [...prev.logs, { id: 'error', timestamp: Date.now() / 1000, message: 'Keine Feature-Instanz für Polling', status: 'error' }] }));
}
} catch (err: any) {
setExecutionModal(prev => ({
...prev,
status: 'error',
logs: [...prev.logs, { id: 'error', timestamp: Date.now() / 1000, message: `Fehler: ${err.message || 'Unbekannter Fehler'}`, status: 'error' }],
}));
showError(`Fehler beim Ausführen: ${err.message}`);
}
};
const handleStopWorkflow = async () => {
if (!executionModal.workflowId) return;
const instanceId = executionModal.featureInstanceId ?? automationWorkflowInstanceId;
if (!instanceId) {
showError('Keine Feature-Instanz für Stopp verfügbar');
return;
}
try {
const stopHeaders: Record<string, string> = {};
if (mandateId) stopHeaders['X-Mandate-Id'] = mandateId;
const stopUrl = `/api/automations/${instanceId}/workflows/${executionModal.workflowId}/stop`;
await request({
url: stopUrl,
method: 'post',
additionalConfig: { headers: stopHeaders },
});
setExecutionModal(prev => ({
...prev,
logs: [...prev.logs, { id: 'stopping', timestamp: Date.now() / 1000, message: 'Workflow wird gestoppt...' }],
}));
} catch (err: any) {
showError(`Fehler beim Stoppen: ${err.message}`);
}
};
const closeExecutionModal = () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setExecutionModal({
visible: false,
automationId: null,
automationLabel: '',
featureInstanceId: null,
workflowId: null,
status: 'starting',
logs: [],
});
};
const handleShowLogs = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
setLogsModal({ visible: true, automation: fullAutomation as Automation || automation });
};
const formatTimestamp = (timestamp: number) => {
if (!timestamp) return '';
return new Date(timestamp * 1000).toLocaleString('de-DE');
};
const formatTime = (timestamp: number) => {
if (!timestamp) return '';
return new Date(timestamp * 1000).toLocaleTimeString('de-DE');
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed': return <FaCheck className={styles.successIcon} />;
case 'error':
case 'failed': return <FaExclamationCircle className={styles.errorIcon} />;
case 'running':
case 'starting': return <FaSpinner className={`${styles.spinningIcon} spinning`} />;
case 'stopped': return <FaStop className={styles.warningIcon} />;
default: return null;
}
};
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Automatisierungen: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}><FaSync /> Erneut versuchen</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Automatisierungen</h1>
<p className={styles.pageSubtitle}>Geplante und automatisierte Workflows</p>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => refetch()} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<>
<button className={styles.secondaryButton} onClick={handleLoadTemplates} disabled={loadingTemplates}>
<FaFileAlt /> {loadingTemplates ? 'Lädt...' : 'Aus Vorlage'}
</button>
<button className={styles.primaryButton} onClick={handleCreateClick}>
<FaPlus /> Neue Automatisierung
</button>
</>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!automations || automations.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Automatisierungen...</span>
</div>
) : !automations || automations.length === 0 ? (
<div className={styles.emptyState}>
<FaRobot className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Automatisierungen vorhanden</h3>
<p className={styles.emptyDescription}>Erstellen Sie eine neue Automatisierung, um Workflows zeitgesteuert auszuführen.</p>
{canCreate && (
<div className={styles.emptyActions}>
<button className={styles.secondaryButton} onClick={handleLoadTemplates}><FaFileAlt /> Aus Vorlage erstellen</button>
<button className={styles.primaryButton} onClick={handleCreateClick}><FaPlus /> Manuell erstellen</button>
</div>
)}
</div>
) : (
<FormGeneratorTable
data={automations as any[]}
columns={columns}
apiEndpoint="/api/automations"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canCreate ? [{ type: 'copy' as const, title: 'Duplizieren', onAction: handleDuplicate }] : []),
...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: 'Bearbeiten' }] : []),
...(canDelete ? [{ type: 'delete' as const, title: 'Löschen', loading: (row: any) => deletingAutomations.has(row.id) }] : []),
]}
customActions={[
{ id: 'execute', icon: <FaRocket />, onClick: handleExecute, title: 'Ausführen', loading: (row: any) => executingAutomations.has(row.id) },
{ id: 'logs', icon: <FaList />, onClick: handleShowLogs, title: 'Ausführungsverlauf' },
]}
onDelete={handleDelete}
hookData={{ refetch, permissions, pagination, handleDelete: handleAutomationDelete, handleInlineUpdate, updateOptimistically }}
emptyMessage="Keine Automatisierungen gefunden"
/>
)}
</div>
{showEditor && editingAutomation && (
<AutomationEditor
mode="definition"
initialData={editingAutomation}
onSave={handleEditorSave}
onCancel={handleEditorCancel}
saving={editorSaving}
/>
)}
{showTemplateModal && (
<div className={styles.modalOverlay} onClick={() => setShowTemplateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Vorlage auswählen</h2>
<button className={styles.modalClose} onClick={() => setShowTemplateModal(false)}><FaTimes /></button>
</div>
<div className={styles.modalContent}>
<div className={styles.templateList}>
{templates.map((template, index) => {
const labelText = template.label ? (typeof template.label === 'string' ? template.label : (template.label as any).de || (template.label as any).en || `Vorlage ${index + 1}`) : `Vorlage ${index + 1}`;
const overviewText = template.overview ? (typeof template.overview === 'string' ? template.overview : (template.overview as any).de || (template.overview as any).en || '') : '';
let parsedTemplate: any = null;
try {
if (template.template) parsedTemplate = typeof template.template === 'string' ? JSON.parse(template.template) : template.template;
} catch { /* ignore */ }
const description = overviewText || parsedTemplate?.overview || parsedTemplate?.tasks?.[0]?.objective || 'Keine Beschreibung';
return (
<div key={template.id || index} className={styles.templateItem}>
<div className={styles.templateHeader}><h4 className={styles.templateTitle}>{labelText}</h4></div>
<p className={styles.templateDescription}>{description}</p>
<button className={styles.primaryButton} onClick={() => handleTemplateSelect(template)}><FaCheck /> Verwenden</button>
</div>
);
})}
</div>
</div>
<div className={styles.modalFooter}>
<button className={styles.secondaryButton} onClick={() => setShowTemplateModal(false)}>Abbrechen</button>
</div>
</div>
</div>
)}
{executionModal.visible && (
<div className={styles.modalOverlay} onClick={closeExecutionModal}>
<div className={styles.modal} onClick={e => e.stopPropagation()} style={{ maxWidth: '700px' }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{getStatusIcon(executionModal.status)} Ausführung: {executionModal.automationLabel}</h2>
<button className={styles.modalClose} onClick={closeExecutionModal}><FaTimes /></button>
</div>
<div className={styles.modalContent}>
<div className={styles.executionStatus}>
<span className={`${styles.statusBadge} ${styles[executionModal.status]}`}>
{executionModal.status === 'starting' && 'Wird gestartet...'}
{executionModal.status === 'running' && 'Läuft...'}
{executionModal.status === 'completed' && 'Abgeschlossen'}
{executionModal.status === 'stopped' && 'Gestoppt'}
{executionModal.status === 'error' && 'Fehler'}
</span>
{executionModal.workflowId && <span className={styles.workflowId}>Workflow: <code>{executionModal.workflowId}</code></span>}
</div>
<div ref={logContainerRef} className={styles.executionLogs} style={{ maxHeight: '400px', overflowY: 'auto', fontFamily: 'monospace', fontSize: '0.875rem' }}>
{executionModal.logs.map((log, index) => (
<div key={log.id || index} className={`${styles.logEntry} ${log.status === 'error' || log.status === 'failed' ? styles.logEntryError : ''}`}>
<span className={styles.logTime}>[{formatTime(log.timestamp)}]</span>
{log.status && <span className={styles.logStatus}><strong>{log.status}:</strong></span>}
<span className={styles.logMessage}>{log.message}</span>
{log.progress !== undefined && log.progress !== null && log.progress < 1 && <span className={styles.logProgress}>({Math.round(log.progress * 100)}%)</span>}
</div>
))}
</div>
</div>
<div className={styles.modalFooter}>
{executionModal.status === 'running' && <button className={styles.dangerButton} onClick={handleStopWorkflow}><FaStop /> Stoppen</button>}
<button className={styles.secondaryButton} onClick={closeExecutionModal}>Schliessen</button>
</div>
</div>
</div>
)}
{logsModal.visible && logsModal.automation && (
<div className={styles.modalOverlay} onClick={() => setLogsModal({ visible: false, automation: null })}>
<div className={styles.modal} onClick={e => e.stopPropagation()} style={{ maxWidth: '700px' }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Ausführungsverlauf: {logsModal.automation.label}</h2>
<button className={styles.modalClose} onClick={() => setLogsModal({ visible: false, automation: null })}><FaTimes /></button>
</div>
<div className={styles.modalContent}>
{(!logsModal.automation.executionLogs || logsModal.automation.executionLogs.length === 0) ? (
<div className={styles.emptyState}><p>Keine Ausführungen vorhanden</p></div>
) : (
<div className={styles.logsHistory}>
{[...logsModal.automation.executionLogs].reverse().map((log, index) => (
<div key={index} className={`${styles.logHistoryItem} ${styles[log.status || 'unknown']}`}>
<div className={styles.logHistoryHeader}>
<span className={styles.logHistoryDate}>{formatTimestamp(log.timestamp)}</span>
<span className={`${styles.statusBadge} ${styles[log.status || 'unknown']}`}>{log.status || 'Unbekannt'}</span>
{log.workflowId && <span className={styles.workflowId}>Workflow: <code>{log.workflowId}</code></span>}
</div>
{log.messages && log.messages.length > 0 && (
<div className={styles.logHistoryMessages}>
{log.messages.map((msg, msgIndex) => <div key={msgIndex} className={styles.logHistoryMessage}>{msg}</div>)}
</div>
)}
</div>
))}
</div>
)}
</div>
<div className={styles.modalFooter}>
<button className={styles.secondaryButton} onClick={() => setLogsModal({ visible: false, automation: null })}>Schliessen</button>
</div>
</div>
</div>
)}
</div>
);
};

View file

@ -1,198 +0,0 @@
/**
* AutomationTemplatesView
*
* View for managing automation templates (CRUD).
* System templates (isSystem=true) are read-only for non-SysAdmin, with duplicate option.
* Instance templates can be managed by instance admins/editors.
*/
import React, { useState, useMemo, useEffect } from 'react';
import { useAutomationTemplates, type AutomationTemplate } from '../../../hooks/useAutomations';
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
import { AutomationEditor } from '../../../components/AutomationEditor';
import { FaSync, FaPlus, FaFileAlt, FaLock } from 'react-icons/fa';
import { useToast } from '../../../contexts/ToastContext';
import { useCurrentUser } from '../../../hooks/useUsers';
import styles from '../../admin/Admin.module.css';
export const AutomationTemplatesView: React.FC = () => {
const {
templates,
attributes,
loading,
error,
permissions,
refetch,
fetchTemplates,
pagination,
createTemplate,
updateTemplate,
deleteTemplate,
duplicateTemplate,
getTemplate,
} = useAutomationTemplates();
const { user: currentUser } = useCurrentUser();
const isSysAdmin = currentUser?.isSysAdmin || false;
const { showSuccess, showError } = useToast();
const [showEditor, setShowEditor] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<AutomationTemplate | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => { refetch(); }, []);
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
const columns = useMemo(() => [
{ key: 'label', label: 'Label', type: 'string' as const, sortable: true, searchable: true, width: 200 },
{ key: 'overview', label: 'Beschreibung', type: 'string' as const, width: 300 },
{ key: 'isSystem', label: 'Typ', type: 'boolean' as const, width: 100, formatter: (value: any) =>
value ? <span style={{ display: 'inline-flex', alignItems: 'center', gap: 4, fontSize: '0.75rem', padding: '0.125rem 0.5rem', borderRadius: 10, background: 'var(--info-color, #3182ce)', color: '#fff' }}><FaLock style={{ fontSize: '0.625rem' }} /> System</span>
: <span style={{ fontSize: '0.75rem', padding: '0.125rem 0.5rem', borderRadius: 10, background: 'var(--success-color, #38a169)', color: '#fff' }}>Instanz</span>
},
{ key: 'sysCreatedByUserName', label: 'Erstellt von', type: 'string' as const, width: 150 },
], []);
const handleEditClick = async (template: AutomationTemplate) => {
const fullTemplate = await getTemplate(template.id);
setEditingTemplate(fullTemplate || template);
setShowEditor(true);
};
const handleCreateClick = () => {
setEditingTemplate(null);
setShowEditor(true);
};
const handleEditorSave = async (data: Partial<AutomationTemplate>) => {
setSaving(true);
try {
if (editingTemplate) {
await updateTemplate(editingTemplate.id, data);
showSuccess('Vorlage aktualisiert');
} else {
await createTemplate(data as any);
showSuccess('Vorlage erstellt');
}
setShowEditor(false);
setEditingTemplate(null);
await refetch();
} catch (err: any) {
showError(`Fehler: ${err.message}`);
} finally {
setSaving(false);
}
};
const handleEditorCancel = () => {
setShowEditor(false);
setEditingTemplate(null);
};
const handleDelete = async (templateId: string): Promise<boolean> => {
try {
await deleteTemplate(templateId);
showSuccess('Vorlage gelöscht');
return true;
} catch (err: any) {
showError(`Fehler: ${err.message}`);
return false;
}
};
const handleDuplicate = async (template: AutomationTemplate) => {
try {
await duplicateTemplate(template.id);
showSuccess('Vorlage dupliziert');
await refetch();
} catch (err: any) {
showError(`Fehler beim Duplizieren: ${err.message}`);
}
};
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Vorlagen: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}><FaSync /> Erneut versuchen</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Automation-Vorlagen</h1>
<p className={styles.pageSubtitle}>Verwalten Sie Ihre Workflow-Vorlagen</p>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => refetch()} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button className={styles.primaryButton} onClick={handleCreateClick}>
<FaPlus /> Neue Vorlage
</button>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!templates || templates.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Vorlagen...</span>
</div>
) : !templates || templates.length === 0 ? (
<div className={styles.emptyState}>
<FaFileAlt className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Vorlagen vorhanden</h3>
<p className={styles.emptyDescription}>Erstellen Sie eine neue Vorlage für Ihre Workflows.</p>
{canCreate && (
<button className={styles.primaryButton} onClick={handleCreateClick}>
<FaPlus /> Vorlage erstellen
</button>
)}
</div>
) : (
<FormGeneratorTable
data={templates as any[]}
columns={columns}
apiEndpoint="/api/automation-templates"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
{ type: 'copy' as const, title: 'Duplizieren', onAction: handleDuplicate },
{ type: 'edit' as const, onAction: handleEditClick, title: 'Bearbeiten', disabled: (row: any) => row.isSystem && !isSysAdmin ? { disabled: true, message: 'System-Vorlagen können nur vom SysAdmin bearbeitet werden' } : !canUpdate ? { disabled: true, message: 'Keine Berechtigung' } : false },
{ type: 'delete' as const, title: 'Löschen', disabled: (row: any) => row.isSystem && !isSysAdmin ? { disabled: true, message: 'System-Vorlagen können nur vom SysAdmin gelöscht werden' } : !canDelete ? { disabled: true, message: 'Keine Berechtigung' } : false },
]}
onDelete={(template) => handleDelete(template.id)}
hookData={{ refetch: fetchTemplates, pagination, handleDelete, attributes }}
emptyMessage="Keine Vorlagen gefunden"
/>
)}
</div>
{showEditor && (
<AutomationEditor
mode="template"
initialData={editingTemplate}
onSave={handleEditorSave}
onCancel={handleEditorCancel}
saving={saving}
/>
)}
</div>
);
};

View file

@ -1,6 +0,0 @@
/**
* Automation Views Export
*/
export { AutomationDefinitionsView } from './AutomationDefinitionsView';
export { AutomationTemplatesView } from './AutomationTemplatesView';

View file

@ -1,38 +0,0 @@
/**
* 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>
);
};

View file

@ -0,0 +1,256 @@
/**
* GraphicalEditorDashboardPage
*
* Overview dashboard with metric cards and recent runs table.
* Uses FormGeneratorTable for the runs list.
*/
import React, { useState, useCallback, useEffect } from 'react';
import { FaSync, FaPlay, FaCog, FaClipboardList, FaChartBar } from 'react-icons/fa';
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import {
fetchMetrics,
fetchCompletedRuns,
type WorkflowMetrics,
type CompletedRun,
} from '../../../api/workflowApi';
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;
}
const STATUS_COLORS: Record<string, string> = {
completed: 'var(--success-color, #28a745)',
failed: 'var(--danger-color, #dc3545)',
running: 'var(--primary-color, #007bff)',
paused: 'var(--warning-color, #ffc107)',
cancelled: 'var(--text-secondary, #666)',
};
interface MetricCardProps {
icon: React.ReactNode;
label: string;
value: string | number;
color?: string;
}
const MetricCard: React.FC<MetricCardProps> = ({ icon, label, value, color }) => (
<div
style={{
background: 'var(--bg-primary, #fff)',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8,
padding: '16px 20px',
display: 'flex',
alignItems: 'center',
gap: 14,
minWidth: 180,
flex: '1 1 180px',
}}
>
<div
style={{
fontSize: 22,
color: color || 'var(--primary-color, #007bff)',
display: 'flex',
alignItems: 'center',
}}
>
{icon}
</div>
<div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)', marginBottom: 2 }}>{label}</div>
<div style={{ fontSize: '1.3rem', fontWeight: 700 }}>{value}</div>
</div>
</div>
);
export const GraphicalEditorDashboardPage: React.FC = () => {
const instanceId = useInstanceId();
const { request } = useApiRequest();
const { showError } = useToast();
const [metrics, setMetrics] = useState<WorkflowMetrics | null>(null);
const [recentRuns, setRecentRuns] = useState<CompletedRun[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
if (!instanceId) return;
setLoading(true);
try {
const [m, runs] = await Promise.all([
fetchMetrics(request, instanceId),
fetchCompletedRuns(request, instanceId, 30),
]);
setMetrics(m);
setRecentRuns(runs);
} catch (e) {
console.error('[graphicalEditor] dashboard load failed', e);
showError('Fehler beim Laden des Dashboards');
} finally {
setLoading(false);
}
}, [instanceId, request, showError]);
useEffect(() => {
load();
}, [load]);
const runColumns: ColumnConfig[] = [
{
key: 'workflowLabel',
label: 'Workflow',
type: 'string',
width: 200,
sortable: true,
formatter: (v: string, row: CompletedRun) => v || row.workflowId || '—',
},
{
key: 'status',
label: 'Status',
type: 'string',
width: 110,
formatter: (v: string) => (
<span style={{ color: STATUS_COLORS[v] || 'inherit', fontWeight: 600, textTransform: 'capitalize' }}>
{v}
</span>
),
},
{
key: 'sysCreatedAt',
label: 'Gestartet',
type: 'number',
width: 150,
formatter: (v: number) => _formatTs(v),
},
{
key: 'sysModifiedAt',
label: 'Beendet',
type: 'number',
width: 150,
formatter: (v: number) => _formatTs(v),
},
];
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}>Dashboard</h1>
<p className={styles.pageSubtitle}>Übersicht über Workflows, Runs und Ressourcen</p>
</div>
<div className={styles.headerActions}>
<button className={styles.secondaryButton} onClick={() => load()} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
</div>
</div>
{/* Metric Cards */}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 12, marginBottom: 24 }}>
<MetricCard
icon={<FaCog />}
label="Workflows"
value={metrics?.workflowCount ?? '—'}
/>
<MetricCard
icon={<FaPlay />}
label="Aktive Workflows"
value={metrics?.activeWorkflows ?? '—'}
color="var(--success-color, #28a745)"
/>
<MetricCard
icon={<FaChartBar />}
label="Runs gesamt"
value={metrics?.totalRuns ?? '—'}
/>
<MetricCard
icon={<FaClipboardList />}
label="Tasks gesamt"
value={metrics?.totalTasks ?? '—'}
/>
</div>
{/* Runs by Status */}
{metrics?.runsByStatus && Object.keys(metrics.runsByStatus).length > 0 && (
<div style={{ marginBottom: 24 }}>
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>Runs nach Status</h3>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{Object.entries(metrics.runsByStatus).map(([status, count]) => (
<span
key={status}
style={{
padding: '4px 12px',
borderRadius: 12,
fontSize: '0.85rem',
fontWeight: 600,
background: 'var(--bg-secondary, #f5f5f5)',
color: STATUS_COLORS[status] || 'inherit',
}}
>
{status}: {count}
</span>
))}
</div>
</div>
)}
{/* Cost summary */}
{metrics && (metrics.totalTokens > 0 || metrics.totalCredits > 0) && (
<div style={{ marginBottom: 24, display: 'flex', gap: 24 }}>
{metrics.totalTokens > 0 && (
<div>
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>Tokens gesamt: </span>
<strong>{metrics.totalTokens.toLocaleString('de-DE')}</strong>
</div>
)}
{metrics.totalCredits > 0 && (
<div>
<span style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #666)' }}>Credits gesamt: </span>
<strong>{metrics.totalCredits.toLocaleString('de-DE', { minimumFractionDigits: 2 })}</strong>
</div>
)}
</div>
)}
{/* Recent Runs Table */}
<h3 style={{ fontSize: '0.95rem', fontWeight: 600, marginBottom: 8 }}>Letzte Runs</h3>
<div className={styles.tableContainer}>
<FormGeneratorTable<CompletedRun>
data={recentRuns}
columns={runColumns}
loading={loading}
pagination={true}
pageSize={15}
searchable={true}
filterable={false}
sortable={true}
selectable={false}
emptyMessage="Noch keine Runs vorhanden."
/>
</div>
</div>
);
};

View file

@ -0,0 +1,58 @@
/**
* GraphicalEditorKeepAlive
*
* Keeps the GraphicalEditorPage mounted across route changes so the canvas
* state, SSE connections, and editor context survive navigation to ANY page
* (other features, admin, settings, etc.).
* Visibility is toggled via CSS `display` instead of mount / unmount.
* Cached mandateId/instanceId are passed as props so the page does not
* depend on URL params (which disappear on non-feature routes).
*/
import React, { useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { GraphicalEditorPage } from './GraphicalEditorPage';
const _GE_EDITOR_ROUTE_RE = /\/mandates\/([^/]+)\/graphicalEditor\/([^/]+)\/editor/;
interface GraphicalEditorKeepAliveProps {
isVisible: boolean;
}
export const GraphicalEditorKeepAlive: React.FC<GraphicalEditorKeepAliveProps> = ({ isVisible }) => {
const location = useLocation();
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const hasEverMountedRef = useRef(false);
const match = location.pathname.match(_GE_EDITOR_ROUTE_RE);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
hasEverMountedRef.current = true;
}
if (!hasEverMountedRef.current) return null;
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<GraphicalEditorPage
persistentInstanceId={cachedInstanceIdRef.current}
persistentMandateId={cachedMandateIdRef.current}
/>
</div>
);
};
export default GraphicalEditorKeepAlive;

View file

@ -0,0 +1,81 @@
/**
* GraphicalEditorPage
*
* n8n-style flow builder with backend-driven node list and UDB sidebar.
*/
import React, { useState, useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
import { useLanguage } from '../../../providers/language/LanguageContext';
import { Automation2FlowEditor as FlowEditor } from '../../../components/FlowEditor';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import styles from '../../FeatureView.module.css';
interface GraphicalEditorPageProps {
persistentInstanceId?: string;
persistentMandateId?: string;
}
export const GraphicalEditorPage: React.FC<GraphicalEditorPageProps> = ({
persistentInstanceId,
persistentMandateId,
}) => {
const urlInstanceId = useInstanceId();
const urlMandateId = useMandateId();
const instanceId = persistentInstanceId || urlInstanceId;
const mandateId = persistentMandateId || urlMandateId;
const [searchParams] = useSearchParams();
const workflowId = searchParams.get('workflowId');
const { currentLanguage } = useLanguage();
const language = (currentLanguage?.slice(0, 2) || 'de') as string;
const [udbTab, setUdbTab] = useState<UdbTab>('files');
const [udbOpen, setUdbOpen] = useState(false);
const udbContext: UdbContext = useMemo(() => ({
mandateId: mandateId || '',
featureInstanceId: instanceId || '',
}), [mandateId, instanceId]);
if (!instanceId) {
return (
<div className={styles.placeholder}>
<h2>Graphical Editor</h2>
<p>Keine Feature-Instanz gefunden.</p>
</div>
);
}
return (
<div style={{ flex: 1, minHeight: 0, display: 'flex' }}>
{udbOpen && (
<div style={{ width: 280, borderRight: '1px solid var(--border-color, #e0e0e0)', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 12px', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}>
<span style={{ fontWeight: 600, fontSize: '13px' }}>Daten</span>
<button onClick={() => setUdbOpen(false)} style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '16px' }}>×</button>
</div>
<UnifiedDataBar
context={udbContext}
activeTab={udbTab}
onTabChange={setUdbTab}
/>
</div>
)}
<div style={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column' }}>
{!udbOpen && (
<button
onClick={() => setUdbOpen(true)}
style={{ position: 'absolute', left: 8, top: 8, zIndex: 10, padding: '4px 10px', border: '1px solid var(--border-color, #ddd)', borderRadius: '6px', background: 'var(--bg-primary, #fff)', cursor: 'pointer', fontSize: '12px' }}
>
Daten
</button>
)}
<FlowEditor
instanceId={instanceId}
language={language}
initialWorkflowId={workflowId}
/>
</div>
</div>
);
};

View file

@ -0,0 +1,269 @@
/**
* GraphicalEditorTemplatesPage
*
* Template management with scope tabs (Meine / Instanz / Mandant / System).
* Uses FormGeneratorTable for the data list.
* Actions: Copy to my workflows, Share (scope upgrade), Delete.
*/
import React, { useState, useCallback, useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { FaCopy, FaSync, FaShareAlt } from 'react-icons/fa';
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import {
fetchTemplates,
copyTemplate,
shareTemplate,
deleteWorkflow,
type AutoWorkflowTemplate,
type AutoTemplateScope,
} from '../../../api/workflowApi';
import { useToast } from '../../../contexts/ToastContext';
import { formatUnixTimestamp } from '../../../utils/time';
import styles from '../../../pages/admin/Admin.module.css';
const SCOPE_LABELS: Record<AutoTemplateScope, string> = {
user: 'Meine',
instance: 'Instanz',
mandate: 'Mandant',
system: 'System',
};
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 GraphicalEditorTemplatesPage: React.FC = () => {
const instanceId = useInstanceId();
const { mandateId } = useParams<{ mandateId: string }>();
const { request } = useApiRequest();
const navigate = useNavigate();
const { showSuccess, showError } = useToast();
const [templates, setTemplates] = useState<AutoWorkflowTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [activeScope, setActiveScope] = useState<AutoTemplateScope | 'all'>('all');
const [copyingId, setCopyingId] = useState<string | null>(null);
const [sharingId, setSharingId] = useState<string | null>(null);
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const load = useCallback(async (paginationParams?: any) => {
if (!instanceId) return;
setLoading(true);
try {
const scope = activeScope === 'all' ? undefined : activeScope;
const result = await fetchTemplates(request, instanceId, scope, paginationParams);
if (result && typeof result === 'object' && 'items' in result) {
setTemplates(result.items as AutoWorkflowTemplate[]);
setPaginationMeta(result.pagination);
} else {
setTemplates(result as AutoWorkflowTemplate[]);
setPaginationMeta(null);
}
} catch (e) {
console.error('[graphicalEditor] load templates failed', e);
showError('Fehler beim Laden der Vorlagen');
} finally {
setLoading(false);
}
}, [instanceId, request, showError, activeScope]);
useEffect(() => {
load();
}, [load]);
const handleDelete = useCallback(
async (templateId: string): Promise<boolean> => {
if (!instanceId) return false;
try {
await deleteWorkflow(request, instanceId, templateId);
showSuccess('Vorlage gelöscht');
await load();
return true;
} catch (e: any) {
showError(`Fehler: ${e?.message || 'Löschen fehlgeschlagen'}`);
return false;
}
},
[instanceId, request, showSuccess, showError, load]
);
const handleCopy = useCallback(
async (row: AutoWorkflowTemplate) => {
if (!instanceId) return;
setCopyingId(row.id);
try {
await copyTemplate(request, instanceId, row.id);
showSuccess('Vorlage als Workflow kopiert');
} catch (e: any) {
showError(`Fehler: ${e?.message || 'Kopieren fehlgeschlagen'}`);
} finally {
setCopyingId(null);
}
},
[instanceId, request, showSuccess, showError]
);
const handleShare = useCallback(
async (row: AutoWorkflowTemplate) => {
if (!instanceId) return;
const currentScope = row.templateScope || 'user';
const nextScope: AutoTemplateScope =
currentScope === 'user' ? 'instance' : currentScope === 'instance' ? 'mandate' : 'mandate';
setSharingId(row.id);
try {
await shareTemplate(request, instanceId, row.id, nextScope);
showSuccess(`Vorlage freigegeben (Scope: ${SCOPE_LABELS[nextScope]})`);
await load();
} catch (e: any) {
showError(`Fehler: ${e?.message || 'Freigabe fehlgeschlagen'}`);
} finally {
setSharingId(null);
}
},
[instanceId, request, showSuccess, showError, load]
);
const handleEdit = useCallback(
(row: AutoWorkflowTemplate) => {
if (!mandateId || !instanceId) return;
navigate(`/mandates/${mandateId}/graphicalEditor/${instanceId}/editor?workflowId=${row.id}`);
},
[mandateId, instanceId, navigate]
);
const columns: ColumnConfig[] = [
{ key: 'label', label: 'Vorlage', type: 'string', width: 220, sortable: true },
{
key: 'templateScope',
label: 'Scope',
type: 'string',
width: 100,
formatter: (v: string) => SCOPE_LABELS[v as AutoTemplateScope] ?? v ?? '—',
},
{
key: 'sharedReadOnly',
label: 'Freigegeben',
type: 'boolean',
width: 100,
formatter: (v: boolean) =>
v ? (
<span style={{ color: 'var(--primary-color, #007bff)', fontWeight: 600 }}>Ja</span>
) : (
<span style={{ color: 'var(--text-secondary, #666)' }}>Nein</span>
),
},
{
key: 'sysCreatedBy',
label: 'Erstellt von',
type: 'string',
width: 140,
},
{
key: 'sysCreatedAt',
label: 'Erstellt',
type: 'number',
width: 140,
formatter: (v: number) => _formatTs(v),
},
];
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}>Workflow-Vorlagen</h1>
<p className={styles.pageSubtitle}>
Vorlagen verwalten, kopieren und freigeben
</p>
</div>
<div className={styles.headerActions} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 4 }}>
{(['all', 'user', 'instance', 'mandate', 'system'] as const).map((s) => (
<button
key={s}
className={activeScope === s ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveScope(s)}
disabled={loading}
>
{s === 'all' ? 'Alle' : SCOPE_LABELS[s as AutoTemplateScope]}
</button>
))}
</div>
<button
className={styles.secondaryButton}
onClick={() => load()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
</div>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable<AutoWorkflowTemplate>
data={templates}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
{
type: 'edit',
title: 'Im Editor öffnen',
onAction: handleEdit,
},
{
type: 'delete',
title: 'Löschen',
},
]}
customActions={[
{
id: 'copy',
icon: <FaCopy />,
title: 'Als Workflow kopieren',
onClick: (row) => handleCopy(row),
loading: (row) => copyingId === row.id,
},
{
id: 'share',
icon: <FaShareAlt />,
title: 'Scope erweitern (freigeben)',
onClick: (row) => handleShare(row),
loading: (row) => sharingId === row.id,
visible: (row) => (row.templateScope || 'user') !== 'system',
},
]}
onDelete={(row) => handleDelete(row.id)}
hookData={{ refetch: load, handleDelete: (id: string) => handleDelete(id), pagination: paginationMeta }}
emptyMessage="Keine Vorlagen gefunden. Erstelle eine Vorlage aus einem bestehenden Workflow."
/>
</div>
</div>
);
};

View file

@ -1,5 +1,5 @@
/**
* Automation2WorkflowsPage
* GraphicalEditorWorkflowsPage
* List of saved workflows with FormGeneratorTable.
* Shows: label, active, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
* Filter: Alle | Aktiv | Inaktiv.
@ -18,7 +18,7 @@ import {
executeGraph,
updateWorkflow,
type Automation2Workflow,
} from '../../../api/automation2Api';
} from '../../../api/workflowApi';
import { useToast } from '../../../contexts/ToastContext';
import { formatUnixTimestamp } from '../../../utils/time';
import styles from '../../../pages/admin/Admin.module.css';
@ -36,7 +36,7 @@ function formatTs(ts?: number): string {
return time;
}
export const Automation2WorkflowsPage: React.FC = () => {
export const GraphicalEditorWorkflowsPage: React.FC = () => {
const instanceId = useInstanceId();
const { mandateId } = useParams<{ mandateId: string }>();
const { request } = useApiRequest();
@ -49,16 +49,23 @@ export const Automation2WorkflowsPage: React.FC = () => {
const [togglingId, setTogglingId] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
const load = useCallback(async () => {
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const load = useCallback(async (paginationParams?: any) => {
if (!instanceId) return;
setLoading(true);
try {
const params =
activeFilter === 'active' ? { active: true } : activeFilter === 'inactive' ? { active: false } : undefined;
const list = await fetchWorkflows(request, instanceId, params);
setWorkflows(list);
const active = activeFilter === 'active' ? true : activeFilter === 'inactive' ? false : undefined;
const result = await fetchWorkflows(request, instanceId, { active, pagination: paginationParams });
if (result && typeof result === 'object' && 'items' in result && !Array.isArray(result)) {
setWorkflows((result as any).items);
setPaginationMeta((result as any).pagination);
} else {
setWorkflows(result as Automation2Workflow[]);
setPaginationMeta(null);
}
} catch (e) {
console.error('[Automation2] load workflows failed', e);
console.error('[graphicalEditor] load workflows failed', e);
showError('Fehler beim Laden der Workflows');
} finally {
setLoading(false);
@ -88,7 +95,7 @@ export const Automation2WorkflowsPage: React.FC = () => {
const handleEdit = useCallback(
(row: Automation2Workflow) => {
if (!mandateId || !instanceId) return;
navigate(`/mandates/${mandateId}/automation2/${instanceId}/editor?workflowId=${row.id}`);
navigate(`/mandates/${mandateId}/graphicalEditor/${instanceId}/editor?workflowId=${row.id}`);
},
[mandateId, instanceId, navigate]
);
@ -209,6 +216,7 @@ export const Automation2WorkflowsPage: React.FC = () => {
const hookData = {
refetch: load,
handleDelete: (id: string) => handleDelete(id),
pagination: paginationMeta,
};
if (!instanceId) {

View file

@ -1,5 +1,5 @@
/**
* Automation2WorkflowsTasksPage
* GraphicalEditorWorkflowsTasksPage
* Tasks only (no workflow grouping).
* Open tasks at top, completed tasks at bottom (expandable, scrollable).
* Each task shows workflow, created, due, step, type, and action.
@ -21,10 +21,10 @@ import {
type Automation2Workflow,
type CompletedRun,
type ApiRequestFunction,
} from '../../../api/automation2Api';
} from '../../../api/workflowApi';
import { useToast } from '../../../contexts/ToastContext';
import { Popup } from '../../../components/UiComponents/Popup';
import { getAcceptStringFromConfig } from '../../../components/Automation2FlowEditor';
import { getAcceptStringFromConfig } from '../../../components/FlowEditor';
import { useFileOperations } from '../../../hooks/useFiles';
import styles from './Automation2WorkflowsTasks.module.css';
@ -70,7 +70,7 @@ function hasManualOrFormInvocation(wf: Automation2Workflow): boolean {
}
/**
* Primary entry for execute matches Automation2WorkflowsPage.handleExecute
* Primary entry for execute matches GraphicalEditorWorkflowsPage.handleExecute
* (manual first, then form or api).
*/
function getPrimaryEntryPoint(wf: Automation2Workflow) {
@ -87,7 +87,7 @@ function primaryKindLabel(kind: string): string {
return kind;
}
export const Automation2WorkflowsTasksPage: React.FC = () => {
export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
const instanceId = useInstanceId();
const { request } = useApiRequest();
const { showSuccess, showError } = useToast();
@ -118,11 +118,11 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
)
);
} catch (we) {
console.error('[Automation2] load startable workflows failed', we);
console.error('[graphicalEditor] load startable workflows failed', we);
setStartableWorkflows([]);
}
} catch (e) {
console.error('[Automation2] load failed', e);
console.error('[graphicalEditor] load failed', e);
} finally {
setLoading(false);
}
@ -139,7 +139,7 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
await completeTask(request, instanceId, taskId, result);
await load();
} catch (e) {
console.error('[Automation2] complete failed', e);
console.error('[graphicalEditor] complete failed', e);
} finally {
setSubmitting(null);
}

Some files were not shown because too many files have changed in this diff Show more