automation unification implemented
This commit is contained in:
parent
b0c5b534ff
commit
3bf79e1ae5
101 changed files with 2321 additions and 6297 deletions
|
|
@ -38,7 +38,7 @@ import { SettingsPage } from './pages/Settings';
|
||||||
import { GDPRPage } from './pages/GDPR';
|
import { GDPRPage } from './pages/GDPR';
|
||||||
import StorePage from './pages/Store';
|
import StorePage from './pages/Store';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
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 { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||||
|
|
@ -195,8 +195,6 @@ function App() {
|
||||||
<Route path="mandates" element={<BillingMandateView />} />
|
<Route path="mandates" element={<BillingMandateView />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
<Route path="subscriptions" element={<AdminSubscriptionsPage />} />
|
||||||
<Route path="automation-events" element={<AdminAutomationEventsPage />} />
|
|
||||||
<Route path="automation-logs" element={<AdminAutomationLogsPage />} />
|
|
||||||
<Route path="logs" element={<AdminLogsPage />} />
|
<Route path="logs" element={<AdminLogsPage />} />
|
||||||
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
<Route path="mandate-wizard" element={<AdminMandateWizardPage />} />
|
||||||
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
<Route path="invitation-wizard" element={<AdminInvitationWizardPage />} />
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
export { ActionsPanel } from './ActionsPanel';
|
|
||||||
export { default } from './ActionsPanel';
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
|
|
@ -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
|
|
@ -1,2 +0,0 @@
|
||||||
export { AutomationEditor, type AutomationEditorProps, type EditorMode } from './AutomationEditor';
|
|
||||||
export { default } from './AutomationEditor';
|
|
||||||
102
src/components/Chat/ChatInput.tsx
Normal file
102
src/components/Chat/ChatInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
89
src/components/Chat/ChatMessageList.tsx
Normal file
89
src/components/Chat/ChatMessageList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
3
src/components/Chat/index.ts
Normal file
3
src/components/Chat/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export { ChatMessageList } from './ChatMessageList';
|
||||||
|
export type { ChatMessage } from './ChatMessageList';
|
||||||
|
export { ChatInput } from './ChatInput';
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
import React, { createContext, useContext, useMemo } from 'react';
|
import React, { createContext, useContext, useMemo } from 'react';
|
||||||
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
import type { CanvasNode, CanvasConnection } from '../editor/FlowCanvas';
|
||||||
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
|
import { getAvailableSources } from '../nodes/shared/dataFlowGraph';
|
||||||
import type { NodeType } from '../../../api/automation2Api';
|
import type { NodeType } from '../../../api/workflowApi';
|
||||||
|
|
||||||
export interface Automation2DataFlowContextValue {
|
export interface Automation2DataFlowContextValue {
|
||||||
currentNodeId: string;
|
currentNodeId: string;
|
||||||
|
|
@ -15,13 +15,19 @@ import {
|
||||||
fetchWorkflow,
|
fetchWorkflow,
|
||||||
createWorkflow,
|
createWorkflow,
|
||||||
updateWorkflow,
|
updateWorkflow,
|
||||||
|
fetchVersions,
|
||||||
|
createDraftVersion,
|
||||||
|
publishVersion,
|
||||||
|
unpublishVersion,
|
||||||
|
archiveVersion,
|
||||||
type NodeType,
|
type NodeType,
|
||||||
type NodeTypeCategory,
|
type NodeTypeCategory,
|
||||||
type Automation2Graph,
|
type Automation2Graph,
|
||||||
type Automation2Workflow,
|
type Automation2Workflow,
|
||||||
type ExecuteGraphResponse,
|
type ExecuteGraphResponse,
|
||||||
type WorkflowEntryPoint,
|
type WorkflowEntryPoint,
|
||||||
} from '../../../api/automation2Api';
|
type AutoVersion,
|
||||||
|
} from '../../../api/workflowApi';
|
||||||
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
import { FlowCanvas, type CanvasNode, type CanvasConnection } from './FlowCanvas';
|
||||||
import { NodeConfigPanel } from './NodeConfigPanel';
|
import { NodeConfigPanel } from './NodeConfigPanel';
|
||||||
import { NodeSidebar } from './NodeSidebar';
|
import { NodeSidebar } from './NodeSidebar';
|
||||||
|
|
@ -36,6 +42,8 @@ import {
|
||||||
import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
|
import { buildNodeOutputsPreview } from '../nodes/shared/outputPreviewRegistry';
|
||||||
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
import { Automation2DataFlowProvider } from '../context/Automation2DataFlowContext';
|
||||||
import { usePrompt } from '../../../hooks/usePrompt';
|
import { usePrompt } from '../../../hooks/usePrompt';
|
||||||
|
import { EditorChatPanel } from './EditorChatPanel';
|
||||||
|
import { RunTracingPanel } from './RunTracingPanel';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
const LOG = '[Automation2]';
|
const LOG = '[Automation2]';
|
||||||
|
|
@ -75,6 +83,11 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(DEFAULT_INVOCATIONS);
|
const [invocations, setInvocations] = useState<WorkflowEntryPoint[]>(DEFAULT_INVOCATIONS);
|
||||||
const [workflowSettingsOpen, setWorkflowSettingsOpen] = useState(false);
|
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']), []);
|
const sidebarExcludedCategories = useMemo(() => new Set(['trigger']), []);
|
||||||
|
|
||||||
|
|
@ -123,6 +136,7 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
...(ep ? { entryPointId: ep } : {}),
|
...(ep ? { entryPointId: ep } : {}),
|
||||||
});
|
});
|
||||||
setExecuteResult(result);
|
setExecuteResult(result);
|
||||||
|
if (result.runId) setTracingRunId(result.runId);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
setExecuteResult({ success: false, error: err instanceof Error ? err.message : String(err) });
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -346,6 +360,98 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
[nodeTypes, language]
|
[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 = () => {
|
const renderSidebar = () => {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -408,10 +514,19 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onExecute={handleExecute}
|
onExecute={handleExecute}
|
||||||
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
|
onWorkflowSettings={() => setWorkflowSettingsOpen(true)}
|
||||||
|
onToggleChat={() => setChatPanelOpen((prev) => !prev)}
|
||||||
saving={saving}
|
saving={saving}
|
||||||
executing={executing}
|
executing={executing}
|
||||||
hasNodes={canvasNodes.length > 0}
|
hasNodes={canvasNodes.length > 0}
|
||||||
executeResult={executeResult}
|
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 className={styles.canvasArea} style={{ display: 'flex', flex: 1, minWidth: 0 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
|
|
@ -450,6 +565,37 @@ export const Automation2FlowEditor: React.FC<Automation2FlowEditorProps> = ({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 />
|
<PromptDialog />
|
||||||
<WorkflowConfigurationModal
|
<WorkflowConfigurationModal
|
||||||
open={workflowSettingsOpen}
|
open={workflowSettingsOpen}
|
||||||
250
src/components/FlowEditor/editor/CanvasHeader.tsx
Normal file
250
src/components/FlowEditor/editor/CanvasHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
145
src/components/FlowEditor/editor/EditorChatPanel.tsx
Normal file
145
src/components/FlowEditor/editor/EditorChatPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
export interface CanvasNode {
|
export interface CanvasNode {
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import type { CanvasNode } from './FlowCanvas';
|
import type { CanvasNode } from './FlowCanvas';
|
||||||
import type { NodeType } from '../../../api/automation2Api';
|
import type { NodeType } from '../../../api/workflowApi';
|
||||||
import type { ApiRequestFunction } from '../../../api/automation2Api';
|
import type { ApiRequestFunction } from '../../../api/workflowApi';
|
||||||
import { getLabel } from '../nodes/shared/utils';
|
import { getLabel } from '../nodes/shared/utils';
|
||||||
import { NODE_CONFIG_REGISTRY } from '../nodes/configs';
|
import { NODE_CONFIG_REGISTRY } from '../nodes/configs';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { NodeType } from '../../../api/automation2Api';
|
import type { NodeType } from '../../../api/workflowApi';
|
||||||
import { getCategoryIcon } from '../nodes/shared/utils';
|
import { getCategoryIcon } from '../nodes/shared/utils';
|
||||||
import type { GetLabelFn } from '../nodes/shared/utils';
|
import type { GetLabelFn } from '../nodes/shared/utils';
|
||||||
import styles from './Automation2FlowEditor.module.css';
|
import styles from './Automation2FlowEditor.module.css';
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
|
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 { CATEGORY_ORDER, HIDDEN_NODE_IDS } from '../nodes/shared/constants';
|
||||||
import { getLabel } from '../nodes/shared/utils';
|
import { getLabel } from '../nodes/shared/utils';
|
||||||
import { NodeListItem } from './NodeListItem';
|
import { NodeListItem } from './NodeListItem';
|
||||||
116
src/components/FlowEditor/editor/RunTracingPanel.tsx
Normal file
116
src/components/FlowEditor/editor/RunTracingPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import type { WorkflowEntryPoint } from '../../../api/automation2Api';
|
import type { WorkflowEntryPoint } from '../../../api/workflowApi';
|
||||||
import {
|
import {
|
||||||
getPrimaryStartKind,
|
getPrimaryStartKind,
|
||||||
buildInvocationsForPrimaryKind,
|
buildInvocationsForPrimaryKind,
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
export { Automation2FlowEditor } from './editor/Automation2FlowEditor';
|
export { Automation2FlowEditor, Automation2FlowEditor as FlowEditor } from './editor/Automation2FlowEditor';
|
||||||
export { FlowCanvas } from './editor/FlowCanvas';
|
export { FlowCanvas } from './editor/FlowCanvas';
|
||||||
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
|
export type { CanvasNode, CanvasConnection } from './editor/FlowCanvas';
|
||||||
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
export { NodeConfigPanel } from './editor/NodeConfigPanel';
|
||||||
|
|
@ -15,7 +15,7 @@ import {
|
||||||
type UserConnection,
|
type UserConnection,
|
||||||
type BrowseEntry,
|
type BrowseEntry,
|
||||||
type ApiRequestFunction,
|
type ApiRequestFunction,
|
||||||
} from '../../../../api/automation2Api';
|
} from '../../../../api/workflowApi';
|
||||||
import { SharepointBrowseTree } from '../../../FolderTree/SharepointBrowseTree';
|
import { SharepointBrowseTree } from '../../../FolderTree/SharepointBrowseTree';
|
||||||
import { HybridStaticRefField } from '../shared/HybridStaticRefField';
|
import { HybridStaticRefField } from '../shared/HybridStaticRefField';
|
||||||
import {
|
import {
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import type { NodeConfigRendererProps } from './types';
|
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> = ({
|
export const EmailNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
params,
|
params,
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
|
|
||||||
import React, { useEffect, useState, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback } from 'react';
|
||||||
import type { NodeConfigRendererProps } from './types';
|
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';
|
import { SharepointBrowseTree } from '../../../FolderTree/SharepointBrowseTree';
|
||||||
|
|
||||||
const browseDetailsStyle: React.CSSProperties = {
|
const browseDetailsStyle: React.CSSProperties = {
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
import { FaGripVertical, FaTimes } from 'react-icons/fa';
|
||||||
import type { FormField, NodeConfigRendererProps } from '../configs/types';
|
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';
|
import styles from '../../editor/Automation2FlowEditor.module.css';
|
||||||
|
|
||||||
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
export const FormNodeConfig: React.FC<NodeConfigRendererProps> = ({
|
||||||
|
|
@ -3,8 +3,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
|
import type { CanvasConnection, CanvasNode } from '../../editor/FlowCanvas';
|
||||||
import type { NodeType } from '../../../../api/automation2Api';
|
import type { NodeType } from '../../../../api/workflowApi';
|
||||||
import type { WorkflowEntryPoint } from '../../../../api/automation2Api';
|
import type { WorkflowEntryPoint } from '../../../../api/workflowApi';
|
||||||
import { getLabel } from '../shared/utils';
|
import { getLabel } from '../shared/utils';
|
||||||
|
|
||||||
export const CANVAS_START_NODE_ID = 'start';
|
export const CANVAS_START_NODE_ID = 'start';
|
||||||
|
|
@ -8,7 +8,7 @@ import type {
|
||||||
Automation2Graph,
|
Automation2Graph,
|
||||||
Automation2GraphNode,
|
Automation2GraphNode,
|
||||||
Automation2Connection,
|
Automation2Connection,
|
||||||
} from '../../../../api/automation2Api';
|
} from '../../../../api/workflowApi';
|
||||||
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
|
import type { CanvasNode, CanvasConnection } from '../../editor/FlowCanvas';
|
||||||
|
|
||||||
export function fromApiGraph(
|
export function fromApiGraph(
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
* Shared types for node config renderers
|
* 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). */
|
/** input.form / trigger.form field row. `clickup_tasks` needs connection + list id; value at runtime is `{ add: [taskId], rem: [] }` (ClickUp relationship). */
|
||||||
export type FormField = {
|
export type FormField = {
|
||||||
|
|
@ -58,6 +58,7 @@
|
||||||
.treeNodeContainer {
|
.treeNodeContainer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.treeNode {
|
.treeNode {
|
||||||
|
|
@ -266,10 +267,12 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-left: auto;
|
position: absolute;
|
||||||
|
right: 0.5rem;
|
||||||
|
top: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.treeNode:hover .nodeActions {
|
.treeNodeContainer:hover > .nodeActions {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -200,7 +200,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||||
setIsExpanded(!isExpanded);
|
setIsExpanded(!isExpanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render the node content
|
// Render the node content (actions are rendered outside to avoid button-in-button nesting)
|
||||||
const nodeContent = (
|
const nodeContent = (
|
||||||
<>
|
<>
|
||||||
{isExpandable && (
|
{isExpandable && (
|
||||||
|
|
@ -221,11 +221,6 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||||
{node.badge}
|
{node.badge}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{node.actions && (
|
|
||||||
<span className={styles.nodeActions} onClick={(e) => e.stopPropagation()}>
|
|
||||||
{node.actions}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -262,6 +257,11 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className={styles.treeNodeContainer}>
|
<div className={styles.treeNodeContainer}>
|
||||||
{nodeElement}
|
{nodeElement}
|
||||||
|
{node.actions && (
|
||||||
|
<span className={styles.nodeActions} onClick={(e) => e.stopPropagation()}>
|
||||||
|
{node.actions}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isExpanded && hasChildren && canRenderChildren && (
|
{isExpanded && hasChildren && canRenderChildren && (
|
||||||
<div className={styles.treeNodeChildren}>
|
<div className={styles.treeNodeChildren}>
|
||||||
{node.children!.map((child, index) => (
|
{node.children!.map((child, index) => (
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
|
FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
|
||||||
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
|
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
|
||||||
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
|
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
|
||||||
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
FaListAlt, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
|
||||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
||||||
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
FaHeadset, FaVideo, FaHatWizard, FaStore, FaUserTie, FaClipboardList,
|
||||||
FaFileContract,
|
FaFileContract,
|
||||||
|
|
@ -114,11 +114,10 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'feature.trustee': <FaBriefcase />,
|
'feature.trustee': <FaBriefcase />,
|
||||||
'feature.realestate': <FaBuilding />,
|
'feature.realestate': <FaBuilding />,
|
||||||
'feature.chatworkflow': <FaPlay />,
|
'feature.chatworkflow': <FaPlay />,
|
||||||
'feature.automation': <FaCogs />,
|
'feature.graphicalEditor': <FaProjectDiagram />,
|
||||||
'feature.automation2': <FaProjectDiagram />,
|
'page.feature.graphicalEditor.editor': <FaProjectDiagram />,
|
||||||
'page.feature.automation2.editor': <FaProjectDiagram />,
|
'page.feature.graphicalEditor.workflows': <FaProjectDiagram />,
|
||||||
'page.feature.automation2.workflows': <FaProjectDiagram />,
|
'page.feature.graphicalEditor.workflows-tasks': <FaClipboardList />,
|
||||||
'page.feature.automation2.workflows-tasks': <FaClipboardList />,
|
|
||||||
'page.feature.chatbot.conversations': <FaComments />,
|
'page.feature.chatbot.conversations': <FaComments />,
|
||||||
'feature.chatbot': <FaComments />,
|
'feature.chatbot': <FaComments />,
|
||||||
'feature.teamsbot': <FaHeadset />,
|
'feature.teamsbot': <FaHeadset />,
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -12,10 +12,12 @@ import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
||||||
import { UserSection } from '../components/Navigation/UserSection';
|
import { UserSection } from '../components/Navigation/UserSection';
|
||||||
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
|
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
|
||||||
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
|
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
|
||||||
|
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
|
||||||
import styles from './MainLayout.module.css';
|
import styles from './MainLayout.module.css';
|
||||||
|
|
||||||
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
||||||
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
|
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/(?:coaching|dossier)/;
|
||||||
|
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// INNER LAYOUT (mit Zugriff auf Store)
|
// INNER LAYOUT (mit Zugriff auf Store)
|
||||||
|
|
@ -27,7 +29,8 @@ const MainLayoutInner: React.FC = () => {
|
||||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||||
const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname);
|
const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname);
|
||||||
const isCommcoachKeepAliveVisible = _COMMCOACH_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
|
// Features laden beim Mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -112,6 +115,7 @@ const MainLayoutInner: React.FC = () => {
|
||||||
|
|
||||||
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
|
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
|
||||||
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
|
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
|
||||||
|
<GraphicalEditorKeepAlive isVisible={isGEEditorKeepAliveVisible} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.outletShell}
|
className={styles.outletShell}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Navigate, useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||||
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
@ -28,13 +28,12 @@ import { ChatbotConversationsView } from './views/chatbot/ChatbotConversationsVi
|
||||||
// RealEstate Views
|
// RealEstate Views
|
||||||
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/realestate';
|
||||||
|
|
||||||
// Automation Views
|
// GraphicalEditor Views
|
||||||
import { AutomationDefinitionsView, AutomationTemplatesView } from './views/automation';
|
import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage';
|
||||||
|
import { GraphicalEditorWorkflowsPage } from './views/graphicalEditor/GraphicalEditorWorkflowsPage';
|
||||||
// Automation2 Views
|
import { GraphicalEditorWorkflowsTasksPage } from './views/graphicalEditor/GraphicalEditorWorkflowsTasksPage';
|
||||||
import { Automation2Page } from './views/automation2/Automation2Page';
|
import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage';
|
||||||
import { Automation2WorkflowsPage } from './views/automation2/Automation2WorkflowsPage';
|
import { GraphicalEditorDashboardPage } from './views/graphicalEditor/GraphicalEditorDashboardPage';
|
||||||
import { Automation2WorkflowsTasksPage } from './views/automation2/Automation2WorkflowsTasksPage';
|
|
||||||
|
|
||||||
// Workspace Views
|
// Workspace Views
|
||||||
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
import { WorkspacePage } from './views/workspace/WorkspacePage';
|
||||||
|
|
@ -130,14 +129,12 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
dashboard: RealEstatePekView,
|
dashboard: RealEstatePekView,
|
||||||
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
'instance-roles': RealEstateInstanceRolesPlaceholder,
|
||||||
},
|
},
|
||||||
automation: {
|
graphicalEditor: {
|
||||||
definitions: AutomationDefinitionsView,
|
editor: GraphicalEditorPage,
|
||||||
templates: AutomationTemplatesView,
|
workflows: GraphicalEditorWorkflowsPage,
|
||||||
},
|
'workflows-tasks': GraphicalEditorWorkflowsTasksPage,
|
||||||
automation2: {
|
templates: GraphicalEditorTemplatesPage,
|
||||||
editor: Automation2Page,
|
dashboard: GraphicalEditorDashboardPage,
|
||||||
workflows: Automation2WorkflowsPage,
|
|
||||||
'workflows-tasks': Automation2WorkflowsTasksPage,
|
|
||||||
},
|
},
|
||||||
workspace: {
|
workspace: {
|
||||||
dashboard: WorkspacePage,
|
dashboard: WorkspacePage,
|
||||||
|
|
@ -175,11 +172,6 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
const { currentLanguage } = useLanguage();
|
const { currentLanguage } = useLanguage();
|
||||||
const { mandateId, instanceId } = useParams<{ mandateId?: string; instanceId?: string }>();
|
const { mandateId, instanceId } = useParams<{ mandateId?: string; instanceId?: string }>();
|
||||||
|
|
||||||
// automation2: Dashboard entfernt → Index/Base-URL auf Editor umleiten
|
|
||||||
if (featureCode === 'automation2' && view === 'dashboard' && mandateId && instanceId) {
|
|
||||||
return <Navigate to={`/mandates/${mandateId}/automation2/${instanceId}/editor`} replace />;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Berechtigungs-Check
|
// Berechtigungs-Check
|
||||||
const viewCode = `${featureCode}-${view}`;
|
const viewCode = `${featureCode}-${view}`;
|
||||||
const canView = useCanViewFeatureView(viewCode);
|
const canView = useCanViewFeatureView(viewCode);
|
||||||
|
|
@ -224,6 +216,11 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GraphicalEditor editor is rendered persistently by GraphicalEditorKeepAlive at MainLayout level.
|
||||||
|
if (featureCode === 'graphicalEditor' && view === 'editor') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
// View-Komponente finden
|
// View-Komponente finden
|
||||||
const featureViews = VIEW_COMPONENTS[featureCode];
|
const featureViews = VIEW_COMPONENTS[featureCode];
|
||||||
if (!featureViews) {
|
if (!featureViews) {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import styles from './Store.module.css';
|
||||||
|
|
||||||
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
const FEATURE_ICONS: Record<string, React.ReactNode> = {
|
||||||
automation: <FaCogs />,
|
automation: <FaCogs />,
|
||||||
automation2: <FaProjectDiagram />,
|
graphicalEditor: <FaProjectDiagram />,
|
||||||
teamsbot: <FaHeadset />,
|
teamsbot: <FaHeadset />,
|
||||||
workspace: <FaComments />,
|
workspace: <FaComments />,
|
||||||
commcoach: <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.',
|
en: 'Create and manage automations to handle recurring tasks efficiently.',
|
||||||
fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.',
|
fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.',
|
||||||
},
|
},
|
||||||
automation2: {
|
graphicalEditor: {
|
||||||
de: 'n8n-style Flow-Automatisierung mit grafischem Editor, RAG und Tools.',
|
de: 'n8n-style Flow-Automatisierung mit grafischem Editor, RAG und Tools.',
|
||||||
en: 'n8n-style flow automation with visual editor, RAG and tools.',
|
en: 'n8n-style flow automation with visual editor, RAG and tools.',
|
||||||
fr: 'Automatisation de flux style n8n avec editeur visuel, RAG et outils.',
|
fr: 'Automatisation de flux style n8n avec editeur visuel, RAG et outils.',
|
||||||
|
|
|
||||||
|
|
@ -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)' }}>✓</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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -434,27 +434,6 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu verwalten.
|
Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu verwalten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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}>
|
<div className={styles.tableContainer}>
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
|
|
|
||||||
|
|
@ -511,26 +511,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
|
||||||
: 'Wählen Sie eine Feature-Instanz aus, um deren Benutzer zu verwalten.'}
|
: 'Wählen Sie eine Feature-Instanz aus, um deren Benutzer zu verwalten.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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}>
|
<div className={styles.tableContainer}>
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
|
|
|
||||||
|
|
@ -348,25 +348,6 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
||||||
Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.
|
Wählen Sie ein Feature aus, um dessen Template-Rollen zu verwalten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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}>
|
<div className={styles.tableContainer}>
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
|
|
|
||||||
|
|
@ -331,25 +331,6 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
Wählen Sie einen Mandanten aus, um dessen Einladungen zu verwalten.
|
Wählen Sie einen Mandanten aus, um dessen Einladungen zu verwalten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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}>
|
<div className={styles.tableContainer}>
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
|
|
|
||||||
|
|
@ -406,29 +406,6 @@ export const AdminMandateRolesPage: React.FC = () => {
|
||||||
Wählen Sie einen Mandanten aus, um dessen Rollen zu verwalten.
|
Wählen Sie einen Mandanten aus, um dessen Rollen zu verwalten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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}>
|
<div className={styles.tableContainer}>
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
|
|
|
||||||
|
|
@ -198,29 +198,7 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && mandates.length === 0 ? (
|
<FormGeneratorTable
|
||||||
<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
|
|
||||||
data={mandates}
|
data={mandates}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/mandates/"
|
apiEndpoint="/api/mandates/"
|
||||||
|
|
@ -265,7 +243,6 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Mandanten gefunden"
|
emptyMessage="Keine Mandanten gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
|
|
|
||||||
|
|
@ -328,26 +328,6 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
Wählen Sie einen Mandanten aus, um dessen Mitglieder zu verwalten.
|
Wählen Sie einen Mandanten aus, um dessen Mitglieder zu verwalten.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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}>
|
<div className={styles.tableContainer}>
|
||||||
<FormGeneratorTable
|
<FormGeneratorTable
|
||||||
|
|
|
||||||
|
|
@ -178,29 +178,7 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!users || users.length === 0) ? (
|
<FormGeneratorTable
|
||||||
<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
|
|
||||||
data={users}
|
data={users}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/users/"
|
apiEndpoint="/api/users/"
|
||||||
|
|
@ -242,7 +220,6 @@ export const AdminUsersPage: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Benutzer gefunden"
|
emptyMessage="Keine Benutzer gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,4 @@ export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
|
||||||
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
|
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
|
||||||
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
|
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
|
||||||
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
||||||
export { AdminAutomationEventsPage } from './AdminAutomationEventsPage';
|
|
||||||
export { AdminAutomationLogsPage } from './AdminAutomationLogsPage';
|
|
||||||
export { AdminLogsPage } from './AdminLogsPage';
|
export { AdminLogsPage } from './AdminLogsPage';
|
||||||
|
|
|
||||||
|
|
@ -302,51 +302,7 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
{loading && (!connections || connections.length === 0) ? (
|
<FormGeneratorTable
|
||||||
<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
|
|
||||||
data={connections}
|
data={connections}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/connections/"
|
apiEndpoint="/api/connections/"
|
||||||
|
|
@ -400,7 +356,6 @@ export const ConnectionsPage: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Verbindungen gefunden"
|
emptyMessage="Keine Verbindungen gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Edit Modal */}
|
{/* Edit Modal */}
|
||||||
|
|
|
||||||
|
|
@ -410,30 +410,7 @@ export const FilesPage: React.FC = () => {
|
||||||
|
|
||||||
{/* Table content */}
|
{/* Table content */}
|
||||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||||
{loading && (!files || files.length === 0) ? (
|
<FormGeneratorTable
|
||||||
<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
|
|
||||||
data={filteredFiles}
|
data={filteredFiles}
|
||||||
columns={columns}
|
columns={columns}
|
||||||
apiEndpoint="/api/files/list"
|
apiEndpoint="/api/files/list"
|
||||||
|
|
@ -490,7 +467,6 @@ export const FilesPage: React.FC = () => {
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Dateien gefunden"
|
emptyMessage="Keine Dateien gefunden"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
/**
|
|
||||||
* Automation Views Export
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { AutomationDefinitionsView } from './AutomationDefinitionsView';
|
|
||||||
export { AutomationTemplatesView } from './AutomationTemplatesView';
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
256
src/pages/views/graphicalEditor/GraphicalEditorDashboardPage.tsx
Normal file
256
src/pages/views/graphicalEditor/GraphicalEditorDashboardPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
58
src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.tsx
Normal file
58
src/pages/views/graphicalEditor/GraphicalEditorKeepAlive.tsx
Normal 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;
|
||||||
81
src/pages/views/graphicalEditor/GraphicalEditorPage.tsx
Normal file
81
src/pages/views/graphicalEditor/GraphicalEditorPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
269
src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
Normal file
269
src/pages/views/graphicalEditor/GraphicalEditorTemplatesPage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Automation2WorkflowsPage
|
* GraphicalEditorWorkflowsPage
|
||||||
* List of saved workflows with FormGeneratorTable.
|
* List of saved workflows with FormGeneratorTable.
|
||||||
* Shows: label, active, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
|
* Shows: label, active, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
|
||||||
* Filter: Alle | Aktiv | Inaktiv.
|
* Filter: Alle | Aktiv | Inaktiv.
|
||||||
|
|
@ -18,7 +18,7 @@ import {
|
||||||
executeGraph,
|
executeGraph,
|
||||||
updateWorkflow,
|
updateWorkflow,
|
||||||
type Automation2Workflow,
|
type Automation2Workflow,
|
||||||
} from '../../../api/automation2Api';
|
} from '../../../api/workflowApi';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import { formatUnixTimestamp } from '../../../utils/time';
|
import { formatUnixTimestamp } from '../../../utils/time';
|
||||||
import styles from '../../../pages/admin/Admin.module.css';
|
import styles from '../../../pages/admin/Admin.module.css';
|
||||||
|
|
@ -36,7 +36,7 @@ function formatTs(ts?: number): string {
|
||||||
return time;
|
return time;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Automation2WorkflowsPage: React.FC = () => {
|
export const GraphicalEditorWorkflowsPage: React.FC = () => {
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
const { mandateId } = useParams<{ mandateId: string }>();
|
const { mandateId } = useParams<{ mandateId: string }>();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
|
|
@ -49,16 +49,23 @@ export const Automation2WorkflowsPage: React.FC = () => {
|
||||||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||||||
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
|
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;
|
if (!instanceId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const params =
|
const active = activeFilter === 'active' ? true : activeFilter === 'inactive' ? false : undefined;
|
||||||
activeFilter === 'active' ? { active: true } : activeFilter === 'inactive' ? { active: false } : undefined;
|
const result = await fetchWorkflows(request, instanceId, { active, pagination: paginationParams });
|
||||||
const list = await fetchWorkflows(request, instanceId, params);
|
if (result && typeof result === 'object' && 'items' in result && !Array.isArray(result)) {
|
||||||
setWorkflows(list);
|
setWorkflows((result as any).items);
|
||||||
|
setPaginationMeta((result as any).pagination);
|
||||||
|
} else {
|
||||||
|
setWorkflows(result as Automation2Workflow[]);
|
||||||
|
setPaginationMeta(null);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Automation2] load workflows failed', e);
|
console.error('[graphicalEditor] load workflows failed', e);
|
||||||
showError('Fehler beim Laden der Workflows');
|
showError('Fehler beim Laden der Workflows');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
@ -88,7 +95,7 @@ export const Automation2WorkflowsPage: React.FC = () => {
|
||||||
const handleEdit = useCallback(
|
const handleEdit = useCallback(
|
||||||
(row: Automation2Workflow) => {
|
(row: Automation2Workflow) => {
|
||||||
if (!mandateId || !instanceId) return;
|
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]
|
[mandateId, instanceId, navigate]
|
||||||
);
|
);
|
||||||
|
|
@ -209,6 +216,7 @@ export const Automation2WorkflowsPage: React.FC = () => {
|
||||||
const hookData = {
|
const hookData = {
|
||||||
refetch: load,
|
refetch: load,
|
||||||
handleDelete: (id: string) => handleDelete(id),
|
handleDelete: (id: string) => handleDelete(id),
|
||||||
|
pagination: paginationMeta,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
/**
|
/**
|
||||||
* Automation2WorkflowsTasksPage
|
* GraphicalEditorWorkflowsTasksPage
|
||||||
* Tasks only (no workflow grouping).
|
* Tasks only (no workflow grouping).
|
||||||
* Open tasks at top, completed tasks at bottom (expandable, scrollable).
|
* Open tasks at top, completed tasks at bottom (expandable, scrollable).
|
||||||
* Each task shows workflow, created, due, step, type, and action.
|
* Each task shows workflow, created, due, step, type, and action.
|
||||||
|
|
@ -21,10 +21,10 @@ import {
|
||||||
type Automation2Workflow,
|
type Automation2Workflow,
|
||||||
type CompletedRun,
|
type CompletedRun,
|
||||||
type ApiRequestFunction,
|
type ApiRequestFunction,
|
||||||
} from '../../../api/automation2Api';
|
} from '../../../api/workflowApi';
|
||||||
import { useToast } from '../../../contexts/ToastContext';
|
import { useToast } from '../../../contexts/ToastContext';
|
||||||
import { Popup } from '../../../components/UiComponents/Popup';
|
import { Popup } from '../../../components/UiComponents/Popup';
|
||||||
import { getAcceptStringFromConfig } from '../../../components/Automation2FlowEditor';
|
import { getAcceptStringFromConfig } from '../../../components/FlowEditor';
|
||||||
import { useFileOperations } from '../../../hooks/useFiles';
|
import { useFileOperations } from '../../../hooks/useFiles';
|
||||||
import styles from './Automation2WorkflowsTasks.module.css';
|
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).
|
* (manual first, then form or api).
|
||||||
*/
|
*/
|
||||||
function getPrimaryEntryPoint(wf: Automation2Workflow) {
|
function getPrimaryEntryPoint(wf: Automation2Workflow) {
|
||||||
|
|
@ -87,7 +87,7 @@ function primaryKindLabel(kind: string): string {
|
||||||
return kind;
|
return kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Automation2WorkflowsTasksPage: React.FC = () => {
|
export const GraphicalEditorWorkflowsTasksPage: React.FC = () => {
|
||||||
const instanceId = useInstanceId();
|
const instanceId = useInstanceId();
|
||||||
const { request } = useApiRequest();
|
const { request } = useApiRequest();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
@ -118,11 +118,11 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} catch (we) {
|
} catch (we) {
|
||||||
console.error('[Automation2] load startable workflows failed', we);
|
console.error('[graphicalEditor] load startable workflows failed', we);
|
||||||
setStartableWorkflows([]);
|
setStartableWorkflows([]);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Automation2] load failed', e);
|
console.error('[graphicalEditor] load failed', e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +139,7 @@ export const Automation2WorkflowsTasksPage: React.FC = () => {
|
||||||
await completeTask(request, instanceId, taskId, result);
|
await completeTask(request, instanceId, taskId, result);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[Automation2] complete failed', e);
|
console.error('[graphicalEditor] complete failed', e);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(null);
|
setSubmitting(null);
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue