From 8d86c166d00cb4ac53ff7efce44d40cbe0e9bc16 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 3 Feb 2026 21:29:53 +0100 Subject: [PATCH 1/2] automation template management and fix admin center --- src/App.tsx | 3 +- src/api/automationApi.ts | 208 ++++++++++--- .../ActionsPanel/ActionsPanel.module.css | 289 ++++++++++++++++++ src/components/ActionsPanel/ActionsPanel.tsx | 216 +++++++++++++ src/components/ActionsPanel/index.ts | 2 + src/config/pageRegistry.tsx | 1 + src/hooks/useAutomations.ts | 157 +++++++++- src/pages/admin/AccessManagementHub.tsx | 3 + src/pages/admin/AdminMandatesPage.tsx | 11 +- src/pages/admin/AdminUsersPage.tsx | 9 +- .../workflows/AutomationTemplatesPage.tsx | 269 ++++++++++++++++ src/pages/workflows/index.ts | 1 + 12 files changed, 1128 insertions(+), 41 deletions(-) create mode 100644 src/components/ActionsPanel/ActionsPanel.module.css create mode 100644 src/components/ActionsPanel/ActionsPanel.tsx create mode 100644 src/components/ActionsPanel/index.ts create mode 100644 src/pages/workflows/AutomationTemplatesPage.tsx diff --git a/src/App.tsx b/src/App.tsx index c9da740..95e21b5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -44,7 +44,7 @@ import { FeatureViewPage } from './pages/FeatureView'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin'; // Workflow Pages (global) -import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows'; +import { PlaygroundPage, WorkflowsPage, AutomationsPage, AutomationTemplatesPage } from './pages/workflows'; // Basedata Pages (global) import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; @@ -114,6 +114,7 @@ function App() { } /> } /> } /> + } /> {/* ============================================== */} diff --git a/src/api/automationApi.ts b/src/api/automationApi.ts index 2004c0b..b6f0628 100644 --- a/src/api/automationApi.ts +++ b/src/api/automationApi.ts @@ -32,17 +32,49 @@ export interface AutomationLog { 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 { - template: { - overview?: string; - tasks?: Array<{ - description?: string; - objective?: string; - [key: string]: any; - }>; - [key: string]: any; + id: string; + label: TextMultilingual; + overview?: TextMultilingual; + template: string; // JSON string with {{KEY:...}} placeholders + _createdAt?: number; + _createdBy?: string; + _createdByUserName?: 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; + execResultLabel: string; }; - parameters?: Record; +} + +export interface WorkflowActionParameter { + name: string; + type: string; + frontendType: string; + required: boolean; + default?: any; + description: string; + frontendOptions?: string | string[]; } export interface CreateAutomationRequest { @@ -188,34 +220,6 @@ export async function executeAutomationApi( }); } -/** - * Fetch automation templates - * Endpoint: GET /api/automations/templates - */ -export async function fetchAutomationTemplates( - request: ApiRequestFunction -): Promise { - const data = await request({ - url: '/api/automations/templates', - method: 'get' - }); - - if (Array.isArray(data)) { - return data; - } - - if (data && typeof data === 'object') { - if (Array.isArray(data.sets)) { - return data.sets; - } - if (Array.isArray(data.templates)) { - return data.templates; - } - } - - return []; -} - /** * Fetch automation attributes for dynamic form generation * Endpoint: GET /api/attributes/AutomationDefinition @@ -238,3 +242,133 @@ export async function fetchAutomationAttributes( return []; } + +// ============================================================================ +// AUTOMATION TEMPLATES API +// ============================================================================ + +/** + * Fetch all automation templates (RBAC-filtered: own templates) + * Endpoint: GET /api/automation-templates + */ +export async function fetchAutomationTemplates( + request: ApiRequestFunction +): Promise { + const data = await request({ + url: '/api/automation-templates', + method: 'get' + }); + + if (data?.items && Array.isArray(data.items)) { + return data.items; + } + return Array.isArray(data) ? data : []; +} + +/** + * Fetch single automation template by ID + * Endpoint: GET /api/automation-templates/{templateId} + */ +export async function fetchAutomationTemplateById( + request: ApiRequestFunction, + templateId: string +): Promise { + 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 +): Promise { + 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 +): Promise { + 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 { + 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 { + 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 { + const data = await request({ + url: '/api/automations/actions', + method: 'get' + }); + + return data?.actions || []; +} diff --git a/src/components/ActionsPanel/ActionsPanel.module.css b/src/components/ActionsPanel/ActionsPanel.module.css new file mode 100644 index 0000000..0aa9d92 --- /dev/null +++ b/src/components/ActionsPanel/ActionsPanel.module.css @@ -0,0 +1,289 @@ +/* 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); +} diff --git a/src/components/ActionsPanel/ActionsPanel.tsx b/src/components/ActionsPanel/ActionsPanel.tsx new file mode 100644 index 0000000..58c2b04 --- /dev/null +++ b/src/components/ActionsPanel/ActionsPanel.tsx @@ -0,0 +1,216 @@ +/** + * 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 = ({ onInsert, onCopy }) => { + const { actions, loading, error, fetchActions } = useWorkflowActions(); + const { showSuccess } = useToast(); + + const [filter, setFilter] = useState(''); + const [expandedMethods, setExpandedMethods] = useState>(new Set()); + const [expandedAction, setExpandedAction] = useState(null); + const [copiedAction, setCopiedAction] = useState(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 = {}; + 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 ( +
+
Lade Actions...
+
+ ); + } + + if (error) { + return ( +
+
Fehler: {error}
+ +
+ ); + } + + return ( +
+
+

Verfügbare Actions

+
+ + setFilter(e.target.value)} + className={styles.searchInput} + /> +
+
+ +
+ {Object.keys(groupedActions).length === 0 ? ( +
Keine Actions gefunden
+ ) : ( + Object.entries(groupedActions).map(([method, methodActions]) => ( +
+ + + {expandedMethods.has(method) && ( +
+ {methodActions.map(action => ( +
+
toggleAction(action.actionId)} + > +
+ {action.action} + {action.description} +
+ +
+ + {expandedAction === action.actionId && ( +
+ {action.parameters.length > 0 && ( +
+
Parameter:
+
    + {action.parameters.map(param => ( +
  • + + {param.name} + {param.required && *} + + {param.type} + {param.description && ( + {param.description} + )} +
  • + ))} +
+
+ )} + +
+
Beispiel JSON:
+
{JSON.stringify(action.exampleJson, null, 2)}
+
+ + {onInsert && ( + + )} +
+ )} +
+ ))} +
+ )} +
+ )) + )} +
+
+ ); +}; + +export default ActionsPanel; diff --git a/src/components/ActionsPanel/index.ts b/src/components/ActionsPanel/index.ts new file mode 100644 index 0000000..6db9c3b --- /dev/null +++ b/src/components/ActionsPanel/index.ts @@ -0,0 +1,2 @@ +export { ActionsPanel } from './ActionsPanel'; +export { default } from './ActionsPanel'; diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 2368238..62f8828 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -39,6 +39,7 @@ export const PAGE_ICONS: Record = { 'page.system.playground': , 'page.system.chats': , 'page.system.automations': , + 'page.system.automation-templates': , 'page.system.prompts': , 'page.system.files': , 'page.system.connections': , diff --git a/src/hooks/useAutomations.ts b/src/hooks/useAutomations.ts index fbf5834..4893fea 100644 --- a/src/hooks/useAutomations.ts +++ b/src/hooks/useAutomations.ts @@ -10,14 +10,29 @@ import { 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, CreateAutomationRequest, UpdateAutomationRequest }; +export type { + Automation, + AutomationTemplate, + TextMultilingual, + WorkflowAction, + CreateAutomationRequest, + UpdateAutomationRequest +}; // Attribute definition interface export interface AttributeDefinition { @@ -446,3 +461,143 @@ export function useAutomationOperations() { updateError }; } + +// ============================================================================ +// AUTOMATION TEMPLATES (DB) HOOK +// ============================================================================ + +/** + * Hook for managing AutomationTemplates from database + */ +export function useAutomationTemplates() { + const [templates, setTemplates] = useState([]); + const [attributes, setAttributes] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { request } = useApiRequest(); + const { checkPermission } = usePermissions(); + const [permissions, setPermissions] = useState(null); + + const fetchTemplates = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await fetchTemplatesApi(request); + setTemplates(data); + } catch (e: any) { + console.error('Error fetching templates:', e); + setError(e.message || 'Failed to fetch templates'); + setTemplates([]); + } 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', '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) => { + return await createAutomationTemplateApi(request, data); + }, [request]); + + const updateTemplate = useCallback(async (templateId: string, data: Partial) => { + return await updateAutomationTemplateApi(request, templateId, data); + }, [request]); + + const deleteTemplate = useCallback(async (templateId: string) => { + await deleteAutomationTemplateApi(request, templateId); + }, [request]); + + const refetch = useCallback(async () => { + await Promise.all([ + fetchTemplates(), + fetchAttributes(), + fetchPermissions() + ]); + }, [fetchTemplates, fetchAttributes, fetchPermissions]); + + return { + templates, + data: templates, // Alias for FormGenerator compatibility + attributes, + loading, + error, + permissions, + refetch, + fetchTemplates, + fetchAttributes, + fetchPermissions, + getTemplate, + createTemplate, + updateTemplate, + deleteTemplate, + }; +} + +// ============================================================================ +// WORKFLOW ACTIONS HOOK +// ============================================================================ + +/** + * Hook for fetching available workflow actions (for Actions panel) + */ +export function useWorkflowActions() { + const [actions, setActions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 + }; +} diff --git a/src/pages/admin/AccessManagementHub.tsx b/src/pages/admin/AccessManagementHub.tsx index f31561b..1c11a25 100644 --- a/src/pages/admin/AccessManagementHub.tsx +++ b/src/pages/admin/AccessManagementHub.tsx @@ -398,6 +398,9 @@ export const AccessManagementHub: React.FC = () => { Mandanten verwalten + + Mandant-Benutzer + {viewMode === 'hierarchy' ? ( diff --git a/src/pages/admin/AdminMandatesPage.tsx b/src/pages/admin/AdminMandatesPage.tsx index 3921bcb..0713a9c 100644 --- a/src/pages/admin/AdminMandatesPage.tsx +++ b/src/pages/admin/AdminMandatesPage.tsx @@ -5,13 +5,15 @@ */ import React, { useState, useMemo } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useAdminMandates, type Mandate } from '../../hooks/useMandates'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; -import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa'; +import { FaPlus, FaSync, FaBuilding, FaUsers } from 'react-icons/fa'; import styles from './Admin.module.css'; export const AdminMandatesPage: React.FC = () => { + const navigate = useNavigate(); const { mandates, attributes, @@ -100,6 +102,13 @@ export const AdminMandatesPage: React.FC = () => {

Verwalten Sie alle Mandanten im System

+
+ +
+ + ); + } + + return ( +
+
+
+

Automation-Vorlagen

+

Verwalten Sie Ihre Workflow-Vorlagen

+
+
+ + {canCreate && ( + + )} +
+
+ +
+ {loading && (!templates || templates.length === 0) ? ( +
+
+ Lade Vorlagen... +
+ ) : !templates || templates.length === 0 ? ( +
+ +

Keine Vorlagen vorhanden

+

+ Erstellen Sie eine neue Vorlage für Ihre Workflows. +

+ {canCreate && ( + + )} +
+ ) : ( + handleDelete(template.id)} + hookData={{ + refetch, + handleDelete, + attributes, + }} + emptyMessage="Keine Vorlagen gefunden" + /> + )} +
+ + {/* Create Modal */} + {showCreateModal && ( +
setShowCreateModal(false)}> +
e.stopPropagation()}> +
+

Neue Vorlage

+ +
+
+ {formAttributes.length === 0 ? ( +
+
+ Lade Formular... +
+ ) : ( + setShowCreateModal(false)} + submitButtonText="Erstellen" + cancelButtonText="Abbrechen" + /> + )} +
+
+
+ )} + + {/* Edit Modal */} + {editingTemplate && ( +
setEditingTemplate(null)}> +
e.stopPropagation()}> +
+

Vorlage bearbeiten

+ +
+
+ {formAttributes.length === 0 ? ( +
+
+ Lade Formular... +
+ ) : ( + setEditingTemplate(null)} + submitButtonText="Speichern" + cancelButtonText="Abbrechen" + /> + )} +
+
+
+ )} +
+ ); +}; + +export default AutomationTemplatesPage; diff --git a/src/pages/workflows/index.ts b/src/pages/workflows/index.ts index a54bffb..2c60e26 100644 --- a/src/pages/workflows/index.ts +++ b/src/pages/workflows/index.ts @@ -1,3 +1,4 @@ export { PlaygroundPage } from './PlaygroundPage'; export { WorkflowsPage } from './WorkflowsPage'; export { AutomationsPage } from './AutomationsPage'; +export { AutomationTemplatesPage } from './AutomationTemplatesPage'; From fe5f4bf188ac149fc1f23f6f757c9dcbe7151593 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 3 Feb 2026 23:42:19 +0100 Subject: [PATCH 2/2] automation template/definition editor --- .../AutomationEditor.module.css | 939 +++++++++++++++ .../AutomationEditor/AutomationEditor.tsx | 1060 +++++++++++++++++ src/components/AutomationEditor/index.ts | 2 + .../trustee/TrusteeExpenseImportView.tsx | 26 +- .../views/trustee/TrusteePositionsView.tsx | 21 +- .../workflows/AutomationTemplatesPage.tsx | 149 +-- src/pages/workflows/AutomationsPage.tsx | 283 ++--- 7 files changed, 2232 insertions(+), 248 deletions(-) create mode 100644 src/components/AutomationEditor/AutomationEditor.module.css create mode 100644 src/components/AutomationEditor/AutomationEditor.tsx create mode 100644 src/components/AutomationEditor/index.ts diff --git a/src/components/AutomationEditor/AutomationEditor.module.css b/src/components/AutomationEditor/AutomationEditor.module.css new file mode 100644 index 0000000..1edfbba --- /dev/null +++ b/src/components/AutomationEditor/AutomationEditor.module.css @@ -0,0 +1,939 @@ +/** + * AutomationEditor Styles + * + * Full-screen editor with form on left and actions panel on right + */ + +.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; +} + +.editorContainer { + background: var(--surface-color, #ffffff); + border-radius: 12px; + width: 100%; + max-width: 1400px; + height: 90vh; + max-height: 900px; + display: flex; + flex-direction: column; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + 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; + } +} diff --git a/src/components/AutomationEditor/AutomationEditor.tsx b/src/components/AutomationEditor/AutomationEditor.tsx new file mode 100644 index 0000000..677daea --- /dev/null +++ b/src/components/AutomationEditor/AutomationEditor.tsx @@ -0,0 +1,1060 @@ +/** + * AutomationEditor + * + * Full-screen editor for AutomationDefinitions and AutomationTemplates. + * Features: + * - Mode toggle: 'definition' vs 'template' + * - Custom form fields for definition/template properties + * - JSON template editor with syntax highlighting + * - Integrated ActionsPanel for action discovery and copy/paste + * - Placeholder extraction and editing + */ + +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { FaTimes, FaSave, FaChevronLeft, FaChevronRight, FaRocket, FaFileAlt, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa'; +import { ActionsPanel } from '../ActionsPanel'; +import { useToast } from '../../contexts/ToastContext'; +import { useLanguage } from '../../providers/language/LanguageContext'; +import { useWorkflowActions } from '../../hooks/useAutomations'; +import { useApiRequest } from '../../hooks/useApi'; +import api from '../../api'; +import type { Automation, AutomationTemplate } from '../../hooks/useAutomations'; +import styles from './AutomationEditor.module.css'; + +// SharePoint folder picker types +interface SiteOption { + value: string; + label: string; + siteId: string; + siteName: string; + webUrl: string; + path: string; +} + +interface FolderOption { + value: string; + label: string; + siteId: string; + folderName: string; + path: string; +} + +// SharePoint Folder Picker Component +interface SharepointFolderPickerProps { + connectionReference: string; + value: string; + onChange: (path: string) => void; +} + +const SharepointFolderPicker: React.FC = ({ connectionReference, value, onChange }) => { + const [siteOptions, setSiteOptions] = useState([]); + const [folderOptions, setFolderOptions] = useState([]); + const [selectedSite, setSelectedSite] = useState(null); + const [currentPath, setCurrentPath] = useState(''); + const [isLoadingSites, setIsLoadingSites] = useState(false); + const [isLoadingFolders, setIsLoadingFolders] = useState(false); + const [error, setError] = useState(null); + const [isExpanded, setIsExpanded] = useState(false); + + // Load sites when connection reference changes + useEffect(() => { + if (!connectionReference || !isExpanded) return; + + const loadSites = async () => { + setIsLoadingSites(true); + setError(null); + try { + const params = new URLSearchParams({ connectionReference }); + const response = await api.get(`/api/sharepoint/folder-options?${params}`); + setSiteOptions(response.data || []); + } catch (err: any) { + console.error('Failed to load sites:', err); + setError(err.response?.data?.detail || 'Fehler beim Laden der SharePoint-Sites'); + setSiteOptions([]); + } finally { + setIsLoadingSites(false); + } + }; + + loadSites(); + }, [connectionReference, isExpanded]); + + // Load folders when site or path changes + useEffect(() => { + if (!selectedSite || !connectionReference) return; + + const loadFolders = async () => { + setIsLoadingFolders(true); + setError(null); + try { + const params = new URLSearchParams({ + connectionReference, + siteId: selectedSite.siteId + }); + if (currentPath) params.append('path', currentPath); + + const response = await api.get(`/api/sharepoint/folder-options?${params}`); + setFolderOptions(response.data || []); + } catch (err: any) { + console.error('Failed to load folders:', err); + setError(err.response?.data?.detail || 'Fehler beim Laden der Ordner'); + setFolderOptions([]); + } finally { + setIsLoadingFolders(false); + } + }; + + loadFolders(); + }, [selectedSite, currentPath, connectionReference]); + + const handleSiteSelect = (siteId: string) => { + const site = siteOptions.find(s => s.siteId === siteId); + setSelectedSite(site || null); + setCurrentPath(''); + setFolderOptions([]); + }; + + const handleFolderNavigate = (folder: FolderOption) => { + setCurrentPath(folder.path); + }; + + const handleFolderSelect = (folder: FolderOption) => { + const fullPath = `${selectedSite?.path || ''}/${folder.path}`; + onChange(fullPath); + setIsExpanded(false); + }; + + const handleGoUp = () => { + if (!currentPath) return; + const parts = currentPath.split('/'); + parts.pop(); + setCurrentPath(parts.join('/')); + }; + + const handleSelectCurrentFolder = () => { + if (!selectedSite) return; + const fullPath = currentPath + ? `${selectedSite.path}/${currentPath}` + : selectedSite.path; + onChange(fullPath); + setIsExpanded(false); + }; + + if (!connectionReference) { + return ( +
+ onChange(e.target.value)} + placeholder="/sites/SiteName/Ordner/Pfad" + /> + + Keine Verbindung ausgewählt - manueller Pfad + +
+ ); + } + + return ( +
+
+ onChange(e.target.value)} + placeholder="/sites/SiteName/Ordner/Pfad" + /> + +
+ + {isExpanded && ( +
+ {error && ( +
+ {error} +
+ )} + + {/* Site Selection */} +
+ + {isLoadingSites ? ( +
+ Lade Sites... +
+ ) : ( + + )} +
+ + {/* Folder Browser */} + {selectedSite && ( +
+
+ {selectedSite.siteName} + {currentPath && / {currentPath}} +
+ +
+ {currentPath && ( +
+ .. +
+ )} + + {isLoadingFolders ? ( +
+ Lade Ordner... +
+ ) : folderOptions.length === 0 ? ( +
+ Keine Unterordner +
+ ) : ( + folderOptions.map(folder => ( +
+ + handleFolderNavigate(folder)} + > + {folder.folderName} + + +
+ )) + )} +
+ + +
+ )} +
+ )} +
+ ); +}; + +// Local TextMultilingual type that supports both 'ge' and 'de' for German +interface LocalTextMultilingual { + en: string; + ge?: string; + de?: string; + fr?: string; + it?: string; +} + +// Placeholder type info extracted from actions +interface PlaceholderTypeInfo { + name: string; + frontendType: string; + frontendOptions?: string | string[]; + description?: string; + required?: boolean; + connectionPlaceholder?: string; // For sharepointFolder: which placeholder holds the connection reference +} + +// Editor mode type +export type EditorMode = 'definition' | 'template'; + +// Props interface +export interface AutomationEditorProps { + /** Editor mode: 'definition' for AutomationDefinition, 'template' for AutomationTemplate */ + mode: EditorMode; + /** Initial data for editing (null for create) */ + initialData?: Automation | AutomationTemplate | null; + /** Callback when saving */ + onSave: (data: any) => Promise; + /** Callback when canceling */ + onCancel: () => void; + /** Whether the editor is in a saving state */ + saving?: boolean; + /** Custom title override */ + title?: string; +} + +// Available languages for multilingual fields +const LANGUAGES = [ + { code: 'en', label: 'English' }, + { code: 'de', label: 'Deutsch' }, + { code: 'fr', label: 'Français' }, +]; + +/** + * Extract {{KEY:name}} placeholders from JSON string + */ +function _extractPlaceholdersFromJson(jsonString: string): string[] { + const regex = /\{\{KEY:(\w+)\}\}/g; + const keys: string[] = []; + let match; + while ((match = regex.exec(jsonString)) !== null) { + if (!keys.includes(match[1])) { + keys.push(match[1]); + } + } + return keys; +} + +/** + * Validate JSON string + */ +function _validateJson(jsonString: string): { valid: boolean; error?: string } { + if (!jsonString || jsonString.trim() === '') { + return { valid: false, error: 'Template JSON darf nicht leer sein' }; + } + try { + JSON.parse(jsonString); + return { valid: true }; + } catch (e: any) { + return { valid: false, error: `Ungültiges JSON: ${e.message}` }; + } +} + +export const AutomationEditor: React.FC = ({ + mode, + initialData, + onSave, + onCancel, + saving = false, + title +}) => { + const { showError, showSuccess } = useToast(); + const { currentLanguage } = useLanguage(); + const jsonTextareaRef = useRef(null); + + // State + const [showActionsPanel, setShowActionsPanel] = useState(true); + const [activeLanguageTab, setActiveLanguageTab] = useState<'en' | 'de' | 'fr'>((currentLanguage as 'en' | 'de' | 'fr') || 'de'); + + // Definition fields + const [label, setLabel] = useState(''); + const [schedule, setSchedule] = useState('0 22 * * *'); + const [active, setActive] = useState(false); + + // Template multilingual fields + const [labelMulti, setLabelMulti] = useState({ en: '', de: '' }); + const [overviewMulti, setOverviewMulti] = useState({ en: '', de: '' }); + + // Common fields + const [templateJson, setTemplateJson] = useState(''); + const [jsonError, setJsonError] = useState(null); + const [placeholders, setPlaceholders] = useState>({}); + const [isSaving, setIsSaving] = useState(false); + + // Actions and placeholder type info + const { actions, fetchActions } = useWorkflowActions(); + const { request } = useApiRequest(); + const [connectionOptions, setConnectionOptions] = useState>([]); + const [loadingConnections, setLoadingConnections] = useState(false); + + // Load actions on mount + useEffect(() => { + fetchActions(); + }, [fetchActions]); + + // Load user connections for userConnection type placeholders + useEffect(() => { + const loadConnections = async () => { + setLoadingConnections(true); + try { + const response = await request({ + url: '/api/connections/', + method: 'get' + }); + // Response is PaginatedResponse with items array + // Backend provides connectionReference and displayLabel computed fields + const connections = response?.items || response || []; + if (Array.isArray(connections)) { + const options = connections + .filter((conn: any) => conn.connectionReference && conn.displayLabel) + .map((conn: any) => ({ + value: conn.connectionReference, + label: conn.displayLabel + })); + setConnectionOptions(options); + } + } catch (err) { + console.error('Error loading connections:', err); + } finally { + setLoadingConnections(false); + } + }; + loadConnections(); + }, [request]); + + // Helper function to extract placeholder names from a value (string or array) + // Returns array of { name, isArrayElement } objects + const extractPlaceholders = (value: any, isArrayElement = false): { name: string; isArrayElement: boolean }[] => { + const placeholders: { name: string; isArrayElement: boolean }[] = []; + const regex = /\{\{KEY:(\w+)\}\}/g; + + if (typeof value === 'string') { + let match; + while ((match = regex.exec(value)) !== null) { + placeholders.push({ name: match[1], isArrayElement }); + } + } else if (Array.isArray(value)) { + for (const item of value) { + // Mark items inside arrays as array elements + placeholders.push(...extractPlaceholders(item, true)); + } + } + + return placeholders; + }; + + // Build a map of placeholder names to their type info from actions + const placeholderTypeMap = useMemo(() => { + const typeMap: Record = {}; + + // Parse the template JSON to find which actions are used + try { + const parsed = JSON.parse(templateJson); + const tasks = parsed?.tasks || []; + + for (const task of tasks) { + // Support both 'actions' and 'actionList' field names + const taskActions = task?.actionList || task?.actions || []; + for (const action of taskActions) { + const execMethod = action?.execMethod; + const execAction = action?.execAction; + const execParams = action?.execParameters || {}; + + // Find matching action definition + const actionDef = actions.find( + a => a.method === execMethod && a.action === execAction + ); + + if (actionDef) { + // First pass: Find connectionReference placeholder for this action + let connectionPlaceholder: string | undefined; + const connectionRefValue = execParams['connectionReference']; + const connPlaceholders = extractPlaceholders(connectionRefValue); + if (connPlaceholders.length > 0) { + connectionPlaceholder = connPlaceholders[0].name; + } + + // Second pass: Check each parameter for {{KEY:xxx}} placeholders + for (const [paramName, paramValue] of Object.entries(execParams)) { + const foundPlaceholders = extractPlaceholders(paramValue); + for (const placeholder of foundPlaceholders) { + const placeholderName = placeholder.name; + // Find parameter definition + const paramDef = actionDef.parameters.find(p => p.name === paramName); + if (paramDef && !typeMap[placeholderName]) { + // If placeholder is inside an array, always use text field + const effectiveFrontendType = placeholder.isArrayElement + ? 'text' + : (paramDef.frontendType || 'text'); + + typeMap[placeholderName] = { + name: placeholderName, + frontendType: effectiveFrontendType, + frontendOptions: paramDef.frontendOptions, + description: paramDef.description, + required: paramDef.required, + // For sharepointFolder types, store the associated connection placeholder + connectionPlaceholder: effectiveFrontendType === 'sharepointFolder' ? connectionPlaceholder : undefined + }; + } + } + } + } + } + } + } catch { + // Ignore parse errors + } + + return typeMap; + }, [templateJson, actions]); + + // Initialize data from initialData + useEffect(() => { + if (initialData) { + if (mode === 'template') { + const tmpl = initialData as AutomationTemplate; + // Convert TextMultilingual (with 'ge') to LocalTextMultilingual (with 'de') + const tmplLabel = tmpl.label || { en: '' }; + const tmplOverview = tmpl.overview || { en: '' }; + setLabelMulti({ + en: tmplLabel.en || '', + de: (tmplLabel as any).de || (tmplLabel as any).ge || '', + fr: tmplLabel.fr || '' + }); + setOverviewMulti({ + en: tmplOverview.en || '', + de: (tmplOverview as any).de || (tmplOverview as any).ge || '', + fr: tmplOverview.fr || '' + }); + } else { + const def = initialData as Automation; + setLabel(def.label || ''); + setSchedule(def.schedule || '0 22 * * *'); + setActive(def.active ?? false); + } + + // Extract template JSON + const template = initialData.template; + if (template) { + const jsonStr = typeof template === 'string' + ? template + : JSON.stringify(template, null, 2); + setTemplateJson(jsonStr); + + // Extract placeholders + const keys = _extractPlaceholdersFromJson(jsonStr); + const existingPlaceholders = (initialData as Automation).placeholders || {}; + const newPlaceholders: Record = {}; + keys.forEach(key => { + newPlaceholders[key] = existingPlaceholders[key] || ''; + }); + setPlaceholders(newPlaceholders); + } + } else { + // New item - set defaults + if (mode === 'template') { + setLabelMulti({ en: '', de: '' }); + setOverviewMulti({ en: '', de: '' }); + } else { + setLabel(''); + setSchedule('0 22 * * *'); + setActive(false); + } + setTemplateJson('{\n "overview": "Beschreibung des Workflows",\n "tasks": [\n {\n "objective": "Aufgabe beschreiben",\n "actions": []\n }\n ]\n}'); + setPlaceholders({}); + } + }, [initialData, mode]); + + // Update placeholders when JSON changes + const handleJsonChange = useCallback((newJson: string) => { + setTemplateJson(newJson); + + // Validate JSON + const validation = _validateJson(newJson); + setJsonError(validation.error || null); + + // Extract and update placeholders + if (validation.valid) { + const keys = _extractPlaceholdersFromJson(newJson); + setPlaceholders(prev => { + const newPlaceholders: Record = {}; + keys.forEach(key => { + newPlaceholders[key] = prev[key] || ''; + }); + return newPlaceholders; + }); + } + }, []); + + // Format JSON (prettify) + const handleFormatJson = useCallback(() => { + try { + const parsed = JSON.parse(templateJson); + const formatted = JSON.stringify(parsed, null, 2); + setTemplateJson(formatted); + setJsonError(null); + showSuccess('JSON formatiert'); + } catch (e: any) { + showError('JSON kann nicht formatiert werden: ' + e.message); + } + }, [templateJson, showSuccess, showError]); + + // Handle placeholder value change + const handlePlaceholderChange = useCallback((key: string, value: string) => { + setPlaceholders(prev => ({ + ...prev, + [key]: value + })); + }, []); + + // Handle multilingual field change + const handleMultilingualChange = useCallback(( + setter: React.Dispatch>, + lang: string, + value: string + ) => { + setter(prev => ({ + ...prev, + [lang]: value + })); + }, []); + + // Insert action JSON from ActionsPanel + const handleInsertAction = useCallback((actionJson: string) => { + if (jsonTextareaRef.current) { + const textarea = jsonTextareaRef.current; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const currentValue = textarea.value; + + // Insert at cursor position + const newValue = currentValue.substring(0, start) + actionJson + currentValue.substring(end); + setTemplateJson(newValue); + + // Validate + const validation = _validateJson(newValue); + setJsonError(validation.error || null); + + // Focus and set cursor after inserted text + setTimeout(() => { + textarea.focus(); + textarea.selectionStart = start + actionJson.length; + textarea.selectionEnd = start + actionJson.length; + }, 0); + + showSuccess('Action eingefügt'); + } + }, [showSuccess]); + + // Handle save + const handleSave = useCallback(async () => { + // Validate JSON + const validation = _validateJson(templateJson); + if (!validation.valid) { + showError(validation.error || 'Ungültiges JSON'); + return; + } + + // Validate required fields + if (mode === 'template') { + if (!labelMulti?.en && !labelMulti?.de) { + showError('Label (mindestens eine Sprache) ist erforderlich'); + return; + } + } else { + if (!label) { + showError('Label ist erforderlich'); + return; + } + } + + setIsSaving(true); + try { + // Build save data based on mode + let saveData: Record; + + if (mode === 'template') { + saveData = { + label: labelMulti, + overview: overviewMulti, + template: templateJson + }; + } else { + saveData = { + label, + schedule, + active, + template: templateJson, + placeholders + }; + } + + // Preserve existing fields from initialData + if (initialData) { + if ('mandateId' in initialData) saveData.mandateId = initialData.mandateId; + if ('featureInstanceId' in initialData) saveData.featureInstanceId = (initialData as Automation).featureInstanceId; + } + + await onSave(saveData); + } catch (err: any) { + showError(`Fehler beim Speichern: ${err.message}`); + } finally { + setIsSaving(false); + } + }, [label, schedule, active, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]); + + // Computed values + const editorTitle = title || (mode === 'template' + ? (initialData ? 'Vorlage bearbeiten' : 'Neue Vorlage') + : (initialData ? 'Automatisierung bearbeiten' : 'Neue Automatisierung')); + + const placeholderKeys = Object.keys(placeholders); + const isFormSaving = saving || isSaving; + + return ( +
+
e.stopPropagation()}> + {/* Header */} +
+
+

{editorTitle}

+ + {mode === 'template' ? ( + <> Template + ) : ( + <> Definition + )} + +
+
+ +
+
+ + {/* Content */} +
+ {/* Form Panel (Left) */} +
+
+

Einstellungen

+
+
+ {/* Form Fields based on mode */} + {mode === 'template' ? ( + /* Template Mode: Multilingual fields */ +
+ {/* Language Tabs */} +
+ {LANGUAGES.map(lang => ( + + ))} +
+ + {/* Label Field */} +
+ + handleMultilingualChange(setLabelMulti, activeLanguageTab, e.target.value)} + placeholder={`Label (${activeLanguageTab.toUpperCase()})`} + /> +

Name der Vorlage

+
+ + {/* Overview Field */} +
+ +