automation template management and fix admin center

This commit is contained in:
ValueOn AG 2026-02-03 21:29:53 +01:00
parent 1863ca9fd0
commit 8d86c166d0
12 changed files with 1128 additions and 41 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

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

@ -0,0 +1,269 @@
/**
* AutomationTemplatesPage
*
* Page for managing automation templates (CRUD).
* Uses FormGeneratorTable for listing and FormGeneratorForm for editing.
*/
import React, { useState, useMemo, useEffect } from 'react';
import { useAutomationTemplates, type AutomationTemplate } from '../../hooks/useAutomations';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaPlus, FaTimes, 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();
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<AutomationTemplate | null>(null);
// 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 },
], []);
// Form attributes for create/edit
const formAttributes = useMemo(() => {
// Filter to editable fields
const editableFields = ['label', 'overview', 'template'];
return (attributes || []).filter(attr => editableFields.includes(attr.name));
}, [attributes]);
// Handle edit click
const handleEditClick = async (template: AutomationTemplate) => {
// Fetch full template data
const fullTemplate = await getTemplate(template.id);
if (fullTemplate) {
setEditingTemplate(fullTemplate);
} else {
setEditingTemplate(template);
}
};
// Handle create submit
const handleCreateSubmit = async (data: Partial<AutomationTemplate>) => {
try {
await createTemplate(data as any);
setShowCreateModal(false);
showSuccess('Vorlage erstellt');
await refetch();
} catch (err: any) {
showError(`Fehler: ${err.message}`);
}
};
// Handle edit submit
const handleEditSubmit = async (data: Partial<AutomationTemplate>) => {
if (!editingTemplate) return;
try {
await updateTemplate(editingTemplate.id, data);
setEditingTemplate(null);
showSuccess('Vorlage aktualisiert');
await refetch();
} catch (err: any) {
showError(`Fehler: ${err.message}`);
}
};
// 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={() => setShowCreateModal(true)}
>
<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={() => setShowCreateModal(true)}
>
<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>
{/* Create Modal */}
{showCreateModal && (
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neue Vorlage</h2>
<button className={styles.modalClose} onClick={() => setShowCreateModal(false)}>
<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}
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
submitButtonText="Erstellen"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
{/* Edit Modal */}
{editingTemplate && (
<div className={styles.modalOverlay} onClick={() => setEditingTemplate(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Vorlage bearbeiten</h2>
<button className={styles.modalClose} onClick={() => setEditingTemplate(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={editingTemplate}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingTemplate(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AutomationTemplatesPage;

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';