Merge pull request #4 from valueonag/feat/automation-template-handling

Feat/automation template handling
This commit is contained in:
Patrick Motsch 2026-02-03 23:47:05 +01:00 committed by GitHub
commit 8fe081b2d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 3259 additions and 188 deletions

View file

@ -44,7 +44,7 @@ import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin';
// Workflow Pages (global) // Workflow Pages (global)
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows'; import { PlaygroundPage, WorkflowsPage, AutomationsPage, AutomationTemplatesPage } from './pages/workflows';
// Basedata Pages (global) // Basedata Pages (global)
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata'; import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
@ -114,6 +114,7 @@ function App() {
<Route path="playground" element={<PlaygroundPage />} /> <Route path="playground" element={<PlaygroundPage />} />
<Route path="list" element={<WorkflowsPage />} /> <Route path="list" element={<WorkflowsPage />} />
<Route path="automations" element={<AutomationsPage />} /> <Route path="automations" element={<AutomationsPage />} />
<Route path="automation-templates" element={<AutomationTemplatesPage />} />
</Route> </Route>
{/* ============================================== */} {/* ============================================== */}

View file

@ -32,17 +32,49 @@ export interface AutomationLog {
messages?: 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 { export interface AutomationTemplate {
template: { id: string;
overview?: string; label: TextMultilingual;
tasks?: Array<{ overview?: TextMultilingual;
description?: string; template: string; // JSON string with {{KEY:...}} placeholders
objective?: string; _createdAt?: number;
[key: string]: any; _createdBy?: string;
}>; _createdByUserName?: string;
[key: string]: any; }
// 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;
}; };
parameters?: Record<string, any>; }
export interface WorkflowActionParameter {
name: string;
type: string;
frontendType: string;
required: boolean;
default?: any;
description: string;
frontendOptions?: string | string[];
} }
export interface CreateAutomationRequest { 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<AutomationTemplate[]> {
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 * Fetch automation attributes for dynamic form generation
* Endpoint: GET /api/attributes/AutomationDefinition * Endpoint: GET /api/attributes/AutomationDefinition
@ -238,3 +242,133 @@ export async function fetchAutomationAttributes(
return []; return [];
} }
// ============================================================================
// AUTOMATION TEMPLATES API
// ============================================================================
/**
* Fetch all automation templates (RBAC-filtered: own templates)
* Endpoint: GET /api/automation-templates
*/
export async function fetchAutomationTemplates(
request: ApiRequestFunction
): Promise<AutomationTemplate[]> {
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<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' | '_createdAt' | '_createdBy'>
): 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 || [];
}

View file

@ -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);
}

View file

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

View file

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

View file

@ -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;
}
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -39,6 +39,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.system.playground': <FaPlay />, 'page.system.playground': <FaPlay />,
'page.system.chats': <FaListAlt />, 'page.system.chats': <FaListAlt />,
'page.system.automations': <FaCogs />, 'page.system.automations': <FaCogs />,
'page.system.automation-templates': <FaFileAlt />,
'page.system.prompts': <FaLightbulb />, 'page.system.prompts': <FaLightbulb />,
'page.system.files': <FaRegFileAlt />, 'page.system.files': <FaRegFileAlt />,
'page.system.connections': <FaLink />, 'page.system.connections': <FaLink />,

View file

@ -10,14 +10,29 @@ import {
deleteAutomationApi, deleteAutomationApi,
executeAutomationApi, executeAutomationApi,
fetchAutomationTemplates as fetchTemplatesApi, fetchAutomationTemplates as fetchTemplatesApi,
fetchAutomationTemplateById,
createAutomationTemplateApi,
updateAutomationTemplateApi,
deleteAutomationTemplateApi,
fetchAutomationTemplateAttributes,
fetchWorkflowActions as fetchWorkflowActionsApi,
type Automation, type Automation,
type AutomationTemplate, type AutomationTemplate,
type TextMultilingual,
type WorkflowAction,
type CreateAutomationRequest, type CreateAutomationRequest,
type UpdateAutomationRequest type UpdateAutomationRequest
} from '../api/automationApi'; } from '../api/automationApi';
// Re-export types // Re-export types
export type { Automation, AutomationTemplate, CreateAutomationRequest, UpdateAutomationRequest }; export type {
Automation,
AutomationTemplate,
TextMultilingual,
WorkflowAction,
CreateAutomationRequest,
UpdateAutomationRequest
};
// Attribute definition interface // Attribute definition interface
export interface AttributeDefinition { export interface AttributeDefinition {
@ -446,3 +461,143 @@ export function useAutomationOperations() {
updateError 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 [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 () => {
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<AutomationTemplate, 'id' | '_createdAt' | '_createdBy'>) => {
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 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<WorkflowAction[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { request } = useApiRequest();
const fetchActions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchWorkflowActionsApi(request);
setActions(data);
} catch (e: any) {
console.error('Error fetching workflow actions:', e);
setError(e.message || 'Failed to fetch actions');
setActions([]);
} finally {
setLoading(false);
}
}, [request]);
return {
actions,
loading,
error,
fetchActions
};
}

View file

@ -398,6 +398,9 @@ export const AccessManagementHub: React.FC = () => {
<Link to="/admin/mandates" className={hubStyles.mandatesLink}> <Link to="/admin/mandates" className={hubStyles.mandatesLink}>
<FaBuilding /> Mandanten verwalten <FaBuilding /> Mandanten verwalten
</Link> </Link>
<Link to="/admin/user-mandates" className={hubStyles.mandatesLink}>
<FaUsers /> Mandant-Benutzer
</Link>
</div> </div>
{viewMode === 'hierarchy' ? ( {viewMode === 'hierarchy' ? (

View file

@ -5,13 +5,15 @@
*/ */
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAdminMandates, type Mandate } from '../../hooks/useMandates'; import { useAdminMandates, type Mandate } from '../../hooks/useMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; 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'; import styles from './Admin.module.css';
export const AdminMandatesPage: React.FC = () => { export const AdminMandatesPage: React.FC = () => {
const navigate = useNavigate();
const { const {
mandates, mandates,
attributes, attributes,
@ -100,6 +102,13 @@ export const AdminMandatesPage: React.FC = () => {
<p className={styles.pageSubtitle}>Verwalten Sie alle Mandanten im System</p> <p className={styles.pageSubtitle}>Verwalten Sie alle Mandanten im System</p>
</div> </div>
<div className={styles.headerActions}> <div className={styles.headerActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/user-mandates')}
>
<FaUsers /> Benutzer-Zuweisungen
</button>
<button <button
className={styles.secondaryButton} className={styles.secondaryButton}
onClick={() => refetch()} onClick={() => refetch()}

View file

@ -9,7 +9,7 @@ import { useNavigate } from 'react-router-dom';
import { useOrgUsers, useUserOperations } from '../../hooks/useUsers'; import { useOrgUsers, useUserOperations } from '../../hooks/useUsers';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUsers, FaKey, FaEnvelopeOpenText } from 'react-icons/fa'; import { FaPlus, FaSync, FaUsers, FaKey, FaEnvelopeOpenText, FaUserShield } from 'react-icons/fa';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
interface User { interface User {
@ -145,6 +145,13 @@ export const AdminUsersPage: React.FC = () => {
<p className={styles.pageSubtitle}>Verwalten Sie alle Benutzer im System</p> <p className={styles.pageSubtitle}>Verwalten Sie alle Benutzer im System</p>
</div> </div>
<div className={styles.headerActions}> <div className={styles.headerActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/user-access-overview')}
>
<FaUserShield /> Zugriffsübersicht
</button>
<button <button
type="button" type="button"
className={styles.secondaryButton} className={styles.secondaryButton}

View file

@ -72,6 +72,8 @@ interface Connection {
status: string; status: string;
externalUsername?: string; externalUsername?: string;
accountName?: string; // Legacy fallback accountName?: string; // Legacy fallback
connectionReference?: string; // Backend computed field: connection:{authority}:{username}
displayLabel?: string; // Backend computed field: human-readable label
} }
interface ExistingAutomation { interface ExistingAutomation {
@ -181,10 +183,10 @@ export const TrusteeExpenseImportView: React.FC = () => {
// Format: "connection:msft:externalUsername" or just the connection ID // Format: "connection:msft:externalUsername" or just the connection ID
const savedConnectionRef = existingAutomation.placeholders?.connectionName || ''; const savedConnectionRef = existingAutomation.placeholders?.connectionName || '';
// Try to find matching connection by externalUsername, accountName, or id // Try to find matching connection by connectionReference, externalUsername, or id
const matchingConn = msftConnections.find(c => const matchingConn = msftConnections.find(c =>
c.connectionReference === savedConnectionRef ||
savedConnectionRef.includes(c.externalUsername || '') || savedConnectionRef.includes(c.externalUsername || '') ||
savedConnectionRef.includes(c.accountName || '') ||
savedConnectionRef.includes(c.id) savedConnectionRef.includes(c.id)
); );
@ -196,6 +198,11 @@ export const TrusteeExpenseImportView: React.FC = () => {
} }
}, [existingAutomation, msftConnections, msftConnection]); }, [existingAutomation, msftConnections, msftConnection]);
// Get connection reference from backend computed field (no frontend logic)
const getConnectionReference = useCallback((conn: Connection): string => {
return conn.connectionReference || `connection:${conn.authority}:${conn.externalUsername}`;
}, []);
// Load SharePoint sites when connected // Load SharePoint sites when connected
const loadSiteOptions = useCallback(async () => { const loadSiteOptions = useCallback(async () => {
if (!msftConnection) return; if (!msftConnection) return;
@ -204,7 +211,9 @@ export const TrusteeExpenseImportView: React.FC = () => {
setError(null); setError(null);
try { try {
const response = await api.get(`/api/sharepoint/${msftConnection.id}/folder-options`); const connectionRef = getConnectionReference(msftConnection);
const params = new URLSearchParams({ connectionReference: connectionRef });
const response = await api.get(`/api/sharepoint/folder-options?${params}`);
setSiteOptions(response.data || []); setSiteOptions(response.data || []);
} catch (err: any) { } catch (err: any) {
console.error('Failed to load sites:', err); console.error('Failed to load sites:', err);
@ -213,7 +222,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
} finally { } finally {
setIsLoadingSites(false); setIsLoadingSites(false);
} }
}, [msftConnection]); }, [msftConnection, getConnectionReference]);
// Load folders when site is selected // Load folders when site is selected
const loadFolderOptions = useCallback(async (siteId: string, path: string = '') => { const loadFolderOptions = useCallback(async (siteId: string, path: string = '') => {
@ -223,10 +232,11 @@ export const TrusteeExpenseImportView: React.FC = () => {
setError(null); setError(null);
try { try {
const params = new URLSearchParams({ siteId }); const connectionRef = getConnectionReference(msftConnection);
const params = new URLSearchParams({ connectionReference: connectionRef, siteId });
if (path) params.append('path', path); if (path) params.append('path', path);
const response = await api.get(`/api/sharepoint/${msftConnection.id}/folder-options?${params}`); const response = await api.get(`/api/sharepoint/folder-options?${params}`);
setFolderOptions(response.data || []); setFolderOptions(response.data || []);
} catch (err: any) { } catch (err: any) {
console.error('Failed to load folders:', err); console.error('Failed to load folders:', err);
@ -235,7 +245,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
} finally { } finally {
setIsLoadingFolders(false); setIsLoadingFolders(false);
} }
}, [msftConnection]); }, [msftConnection, getConnectionReference]);
useEffect(() => { useEffect(() => {
if (msftConnection) { if (msftConnection) {
@ -509,7 +519,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
<option value="">Select a Microsoft account...</option> <option value="">Select a Microsoft account...</option>
{msftConnections.map((conn) => ( {msftConnections.map((conn) => (
<option key={conn.id} value={conn.id}> <option key={conn.id} value={conn.id}>
{conn.accountName || conn.id} {conn.displayLabel || conn.externalUsername || conn.id}
</option> </option>
))} ))}
</select> </select>

View file

@ -50,11 +50,11 @@ export const TrusteePositionsView: React.FC = () => {
}, [instanceId]); }, [instanceId]);
// Hidden columns (not shown in table view, but available in form) // Hidden columns (not shown in table view, but available in form)
const hiddenColumns = ['desc']; const hiddenColumns = ['desc', 'featureInstanceId', 'mandateId'];
// Generate columns from attributes // Generate columns from attributes + add system columns
const columns = useMemo(() => { const columns = useMemo(() => {
return (attributes || []) const attrColumns = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name)) .filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => ({ .map(attr => ({
key: attr.name, key: attr.name,
@ -67,6 +67,21 @@ export const TrusteePositionsView: React.FC = () => {
minWidth: attr.minWidth || 100, minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400, maxWidth: attr.maxWidth || 400,
})); }));
// Add _createdAt system column
attrColumns.push({
key: '_createdAt',
label: 'Erstellt am',
type: 'timestamp' as any,
sortable: true,
filterable: false,
searchable: false,
width: 150,
minWidth: 120,
maxWidth: 200,
});
return attrColumns;
}, [attributes]); }, [attributes]);
// Check permissions // Check permissions

View file

@ -0,0 +1,216 @@
/**
* AutomationTemplatesPage
*
* Page for managing automation templates (CRUD).
* Uses FormGeneratorTable for listing and AutomationEditor for editing.
*/
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 } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import styles from '../admin/Admin.module.css';
export const AutomationTemplatesPage: React.FC = () => {
const {
templates,
attributes,
loading,
error,
permissions,
refetch,
createTemplate,
updateTemplate,
deleteTemplate,
getTemplate,
} = useAutomationTemplates();
const { showSuccess, showError } = useToast();
// Editor states
const [showEditor, setShowEditor] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<AutomationTemplate | null>(null);
const [saving, setSaving] = useState(false);
// Initial fetch
useEffect(() => {
refetch();
}, []);
// Check permissions
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
// Table columns - FormGeneratorTable auto-renders TextMultilingual in user language
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: '_createdByUserName', label: 'Erstellt von', type: 'string' as const, width: 150 },
], []);
// Handle edit click - open editor with template data
const handleEditClick = async (template: AutomationTemplate) => {
// Fetch full template data
const fullTemplate = await getTemplate(template.id);
setEditingTemplate(fullTemplate || template);
setShowEditor(true);
};
// Handle create click - open editor for new template
const handleCreateClick = () => {
setEditingTemplate(null);
setShowEditor(true);
};
// Handle editor save
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);
}
};
// Handle editor cancel
const handleEditorCancel = () => {
setShowEditor(false);
setEditingTemplate(null);
};
// Handle delete by ID (used by DeleteActionButton via hookData)
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;
}
};
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}>
<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}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
}] : []),
]}
onDelete={(template) => handleDelete(template.id)}
hookData={{
refetch,
handleDelete,
attributes,
}}
emptyMessage="Keine Vorlagen gefunden"
/>
)}
</div>
{/* Automation Editor */}
{showEditor && (
<AutomationEditor
mode="template"
initialData={editingTemplate}
onSave={handleEditorSave}
onCancel={handleEditorCancel}
saving={saving}
/>
)}
</div>
);
};
export default AutomationTemplatesPage;

View file

@ -8,7 +8,7 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations'; import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { AutomationEditor } from '../../components/AutomationEditor';
import { FaSync, FaRobot, FaRocket, FaPlus, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner } from 'react-icons/fa'; import { FaSync, FaRobot, FaRocket, FaPlus, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi'; import { useApiRequest } from '../../hooks/useApi';
@ -63,9 +63,12 @@ export const AutomationsPage: React.FC = () => {
const { showSuccess, showError, showInfo } = useToast(); const { showSuccess, showError, showInfo } = useToast();
const { request } = useApiRequest(); const { request } = useApiRequest();
// Modal states // Editor states
const [showCreateModal, setShowCreateModal] = useState(false); const [showEditor, setShowEditor] = useState(false);
const [editingAutomation, setEditingAutomation] = useState<Automation | null>(null); const [editingAutomation, setEditingAutomation] = useState<Automation | null>(null);
const [editorSaving, setEditorSaving] = useState(false);
// Template selection states
const [showTemplateModal, setShowTemplateModal] = useState(false); const [showTemplateModal, setShowTemplateModal] = useState(false);
const [templates, setTemplates] = useState<AutomationTemplate[]>([]); const [templates, setTemplates] = useState<AutomationTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false); const [loadingTemplates, setLoadingTemplates] = useState(false);
@ -146,46 +149,76 @@ export const AutomationsPage: React.FC = () => {
const canUpdate = permissions?.update !== 'n'; const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n'; const canDelete = permissions?.delete !== 'n';
// Handle edit click // Handle edit click - open editor with automation data
const handleEditClick = async (automation: Automation) => { const handleEditClick = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id); const fullAutomation = await fetchAutomationById(automation.id);
if (fullAutomation) { setEditingAutomation(fullAutomation as Automation || automation);
setEditingAutomation(fullAutomation as Automation); setShowEditor(true);
}
}; };
// Handle create submit // Handle create click - open editor for new automation
const handleCreateSubmit = async (data: Partial<Automation>) => { const handleCreateClick = () => {
// Validate context - mandateId and featureInstanceId are required // Pre-fill with context
const newAutomation: Partial<Automation> = {
mandateId: mandateId,
featureInstanceId: featureInstanceId,
label: '',
schedule: '0 22 * * *',
active: false,
placeholders: {},
};
setEditingAutomation(newAutomation as Automation);
setShowEditor(true);
};
// Handle editor save
const handleEditorSave = async (data: Partial<Automation>) => {
// Validate context
if (!mandateId || !featureInstanceId) { if (!mandateId || !featureInstanceId) {
showError('Fehler: Kein aktiver Mandant oder Feature-Instanz gefunden'); showError('Fehler: Kein aktiver Mandant oder Feature-Instanz gefunden');
return; return;
} }
// Add required fields from context setEditorSaving(true);
const createData = { try {
...data, // Add required context fields
mandateId: mandateId, const saveData = {
featureInstanceId: featureInstanceId, ...data,
}; mandateId: mandateId,
featureInstanceId: featureInstanceId,
const result = await handleAutomationCreate(createData as any); };
if (result) {
setShowCreateModal(false); if (editingAutomation?.id) {
showSuccess('Automatisierung erstellt'); // Update existing - include id in payload for backend validation
await refetch(); 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 {
// Create new
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);
} }
}; };
// Handle edit submit // Handle editor cancel
const handleEditSubmit = async (data: Partial<Automation>) => { const handleEditorCancel = () => {
if (!editingAutomation) return; setShowEditor(false);
const success = await handleAutomationUpdate(editingAutomation.id, data as any); setEditingAutomation(null);
if (success) {
setEditingAutomation(null);
showSuccess('Automatisierung aktualisiert');
await refetch();
}
}; };
// Handle delete single automation (confirmation handled by DeleteActionButton) // Handle delete single automation (confirmation handled by DeleteActionButton)
@ -215,8 +248,8 @@ export const AutomationsPage: React.FC = () => {
} }
}; };
// Handle template selection // Handle template selection - open editor with template data pre-filled
const handleTemplateSelect = async (template: AutomationTemplate) => { const handleTemplateSelect = (template: AutomationTemplate) => {
setShowTemplateModal(false); setShowTemplateModal(false);
// Validate context - mandateId and featureInstanceId are required // Validate context - mandateId and featureInstanceId are required
@ -225,39 +258,51 @@ export const AutomationsPage: React.FC = () => {
return; return;
} }
// Get label from template (can be multilingual)
let templateLabel = 'Neue Automatisierung';
if (template.label) {
if (typeof template.label === 'string') {
templateLabel = template.label;
} else if (typeof template.label === 'object') {
// TextMultilingual - use German or English
templateLabel = (template.label as any).de || (template.label as any).en || 'Neue Automatisierung';
}
} else if (template.overview) {
// TextMultilingual - use German or English
templateLabel = typeof template.overview === 'string'
? template.overview
: ((template.overview as any).de || (template.overview as any).en || 'Neue Automatisierung');
}
// Convert placeholder values to strings (backend expects Dict[str, str]) // Convert placeholder values to strings (backend expects Dict[str, str])
// Arrays and objects are converted to JSON strings
const convertedPlaceholders: Record<string, string> = {}; const convertedPlaceholders: Record<string, string> = {};
const templateParams = template.parameters || {}; const templateParams = (template as any).parameters || {};
for (const [key, value] of Object.entries(templateParams)) { for (const [key, value] of Object.entries(templateParams)) {
if (value === null || value === undefined) { if (value === null || value === undefined) {
convertedPlaceholders[key] = ''; convertedPlaceholders[key] = '';
} else if (Array.isArray(value) || (typeof value === 'object' && value !== null)) { } else if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
// Convert complex structures to JSON strings
convertedPlaceholders[key] = JSON.stringify(value); convertedPlaceholders[key] = JSON.stringify(value);
} else { } else {
// Keep primitive values as strings
convertedPlaceholders[key] = String(value); convertedPlaceholders[key] = String(value);
} }
} }
// Pre-fill form with template data including required fields // Pre-fill form with template data and open editor for user to customize
const prefillData: Partial<Automation> = { const prefillData: Partial<Automation> = {
mandateId: mandateId, mandateId: mandateId,
featureInstanceId: featureInstanceId, featureInstanceId: featureInstanceId,
label: template.template?.overview || 'Neue Automatisierung', label: templateLabel,
template: JSON.stringify(template.template, null, 2), template: typeof template.template === 'string'
? template.template
: JSON.stringify(template.template, null, 2),
placeholders: convertedPlaceholders, placeholders: convertedPlaceholders,
active: false, active: false,
schedule: '0 */4 * * *', schedule: '0 22 * * *',
}; };
// Create automation directly // Open editor with pre-filled data (no id = create mode)
const result = await handleAutomationCreate(prefillData as any); setEditingAutomation(prefillData as Automation);
if (result) { setShowEditor(true);
showSuccess('Automatisierung aus Vorlage erstellt');
await refetch();
}
}; };
// Poll workflow logs // Poll workflow logs
@ -423,13 +468,6 @@ export const AutomationsPage: React.FC = () => {
}); });
}; };
// Form attributes for create/edit modal
const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', '_createdBy', '_createdAt', '_modifiedAt', 'status', 'executionLogs'];
return (attributes || [])
.filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
// Format timestamp // Format timestamp
const formatTimestamp = (timestamp: number) => { const formatTimestamp = (timestamp: number) => {
if (!timestamp) return ''; if (!timestamp) return '';
@ -502,7 +540,7 @@ export const AutomationsPage: React.FC = () => {
</button> </button>
<button <button
className={styles.primaryButton} className={styles.primaryButton}
onClick={() => setShowCreateModal(true)} onClick={handleCreateClick}
> >
<FaPlus /> Neue Automatisierung <FaPlus /> Neue Automatisierung
</button> </button>
@ -534,7 +572,7 @@ export const AutomationsPage: React.FC = () => {
</button> </button>
<button <button
className={styles.primaryButton} className={styles.primaryButton}
onClick={() => setShowCreateModal(true)} onClick={handleCreateClick}
> >
<FaPlus /> Manuell erstellen <FaPlus /> Manuell erstellen
</button> </button>
@ -593,67 +631,15 @@ export const AutomationsPage: React.FC = () => {
)} )}
</div> </div>
{/* Create Modal */} {/* Automation Editor */}
{showCreateModal && ( {showEditor && editingAutomation && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}> <AutomationEditor
<div className={styles.modal} onClick={e => e.stopPropagation()}> mode="definition"
<div className={styles.modalHeader}> initialData={editingAutomation}
<h2 className={styles.modalTitle}>Neue Automatisierung</h2> onSave={handleEditorSave}
<button className={styles.modalClose} onClick={() => setShowCreateModal(false)}> onCancel={handleEditorCancel}
<FaTimes /> saving={editorSaving}
</button> />
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
submitButtonText="Erstellen"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingAutomation && (
<div className={styles.modalOverlay} onClick={() => setEditingAutomation(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Automatisierung bearbeiten</h2>
<button className={styles.modalClose} onClick={() => setEditingAutomation(null)}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingAutomation}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingAutomation(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)} )}
{/* Template Selection Modal */} {/* Template Selection Modal */}
@ -668,26 +654,51 @@ export const AutomationsPage: React.FC = () => {
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
<div className={styles.templateList}> <div className={styles.templateList}>
{templates.map((template, index) => ( {templates.map((template, index) => {
<div key={index} className={styles.templateItem}> // Get label from TextMultilingual
<div className={styles.templateHeader}> const labelText = template.label
<h4 className={styles.templateTitle}> ? (typeof template.label === 'string'
{template.template?.overview || `Vorlage ${index + 1}`} ? template.label
</h4> : (template.label as any).de || (template.label as any).en || `Vorlage ${index + 1}`)
: `Vorlage ${index + 1}`;
// Get overview from TextMultilingual
const overviewText = template.overview
? (typeof template.overview === 'string'
? template.overview
: (template.overview as any).de || (template.overview as any).en || '')
: '';
// Try to parse template JSON for additional info
let parsedTemplate: any = null;
try {
if (template.template) {
parsedTemplate = typeof template.template === 'string'
? JSON.parse(template.template)
: template.template;
}
} catch { /* ignore parse errors */ }
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>
<p className={styles.templateDescription}> );
{template.template?.tasks?.[0]?.description || })}
template.template?.tasks?.[0]?.objective ||
'Keine Beschreibung'}
</p>
<button
className={styles.primaryButton}
onClick={() => handleTemplateSelect(template)}
>
<FaCheck /> Verwenden
</button>
</div>
))}
</div> </div>
</div> </div>
<div className={styles.modalFooter}> <div className={styles.modalFooter}>

View file

@ -1,3 +1,4 @@
export { PlaygroundPage } from './PlaygroundPage'; export { PlaygroundPage } from './PlaygroundPage';
export { WorkflowsPage } from './WorkflowsPage'; export { WorkflowsPage } from './WorkflowsPage';
export { AutomationsPage } from './AutomationsPage'; export { AutomationsPage } from './AutomationsPage';
export { AutomationTemplatesPage } from './AutomationTemplatesPage';