fix: created feature view for automation page instead of using workflow pages, updated to use pop-up component instead of hard-code

This commit is contained in:
Ida Dittrich 2026-03-06 15:42:20 +01:00
parent b13e6f105c
commit bc94e52904
10 changed files with 456 additions and 766 deletions

View file

@ -40,7 +40,8 @@ import StorePage from './pages/Store';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
import { PlaygroundPage, WorkflowsPage } from './pages/workflows';
import { AutomationDefinitionsView } from './pages/views/automation';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin } from './pages/billing';
function App() {
@ -106,7 +107,7 @@ function App() {
<Route path="workflows">
<Route path="playground" element={<PlaygroundPage />} />
<Route path="list" element={<WorkflowsPage />} />
<Route path="automations" element={<AutomationsPage />} />
<Route path="automations" element={<AutomationDefinitionsView />} />
</Route>
{/* ============================================== */}

View file

@ -4,6 +4,7 @@
* Full-screen editor with form on left and actions panel on right
*/
/* Used when AutomationEditor had custom overlay - kept for reference, Popup is used now */
.editorOverlay {
position: fixed;
top: 0;
@ -18,16 +19,22 @@
padding: 1rem;
}
/* Popup customisation for fullscreen editor - fill content area */
.editorPopup :global([class*="content"]) {
padding: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.editorContainer {
background: var(--surface-color, #ffffff);
border-radius: 12px;
width: 100%;
max-width: 1400px;
height: 90vh;
max-height: 900px;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
overflow: hidden;
}

View file

@ -11,7 +11,8 @@
*/
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { FaTimes, FaSave, FaChevronLeft, FaChevronRight, FaRocket, FaFileAlt, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa';
import { FaSave, FaChevronLeft, FaChevronRight, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa';
import { Popup } from '../UiComponents/Popup';
import { ActionsPanel } from '../ActionsPanel';
import { ProviderMultiSelect } from '../ProviderSelector';
import { useToast } from '../../contexts/ToastContext';
@ -177,99 +178,103 @@ const SharepointFolderPicker: React.FC<SharepointFolderPickerProps> = ({ connect
</button>
</div>
{isExpanded && (
<div className={styles.sharepointFolderBrowser}>
{error && (
<div className={styles.sharepointError}>
<FaExclamationTriangle /> {error}
</div>
)}
{/* Site Selection */}
<div className={styles.sharepointSection}>
<label>SharePoint Site:</label>
{isLoadingSites ? (
<div className={styles.sharepointLoading}>
<FaSpinner className={styles.spinner} /> Lade Sites...
</div>
) : (
<select
className={styles.sharepointSelect}
value={selectedSite?.siteId || ''}
onChange={e => handleSiteSelect(e.target.value)}
>
<option value="">-- Site auswählen --</option>
{siteOptions.map(site => (
<option key={site.siteId} value={site.siteId}>
{site.siteName}
</option>
))}
</select>
)}
<Popup
isOpen={isExpanded}
title="SharePoint Ordner auswählen"
onClose={() => setIsExpanded(false)}
size="large"
closable={true}
>
{error && (
<div className={styles.sharepointError}>
<FaExclamationTriangle /> {error}
</div>
{/* Folder Browser */}
{selectedSite && (
<div className={styles.sharepointSection}>
<div className={styles.sharepointBreadcrumb}>
<span>{selectedSite.siteName}</span>
{currentPath && <span> / {currentPath}</span>}
</div>
<div className={styles.sharepointFolderList}>
{currentPath && (
<div
className={styles.sharepointFolderItem}
onClick={handleGoUp}
>
<FaArrowUp /> ..
</div>
)}
{isLoadingFolders ? (
<div className={styles.sharepointLoading}>
<FaSpinner className={styles.spinner} /> Lade Ordner...
</div>
) : folderOptions.length === 0 ? (
<div className={styles.sharepointEmpty}>
Keine Unterordner
</div>
) : (
folderOptions.map(folder => (
<div
key={folder.path}
className={styles.sharepointFolderItem}
>
<FaFolder />
<span
className={styles.folderName}
onClick={() => handleFolderNavigate(folder)}
>
{folder.folderName}
</span>
<button
type="button"
className={styles.selectFolderButton}
onClick={() => handleFolderSelect(folder)}
>
Auswählen
</button>
</div>
))
)}
</div>
<button
type="button"
className={styles.selectCurrentFolderButton}
onClick={handleSelectCurrentFolder}
>
Aktuellen Ordner verwenden: {currentPath || '(Root)'}
</button>
)}
{/* Site Selection */}
<div className={styles.sharepointSection}>
<label>SharePoint Site:</label>
{isLoadingSites ? (
<div className={styles.sharepointLoading}>
<FaSpinner className={styles.spinner} /> Lade Sites...
</div>
) : (
<select
className={styles.sharepointSelect}
value={selectedSite?.siteId || ''}
onChange={e => handleSiteSelect(e.target.value)}
>
<option value="">-- Site auswählen --</option>
{siteOptions.map(site => (
<option key={site.siteId} value={site.siteId}>
{site.siteName}
</option>
))}
</select>
)}
</div>
)}
{/* Folder Browser */}
{selectedSite && (
<div className={styles.sharepointSection}>
<div className={styles.sharepointBreadcrumb}>
<span>{selectedSite.siteName}</span>
{currentPath && <span> / {currentPath}</span>}
</div>
<div className={styles.sharepointFolderList}>
{currentPath && (
<div
className={styles.sharepointFolderItem}
onClick={handleGoUp}
>
<FaArrowUp /> ..
</div>
)}
{isLoadingFolders ? (
<div className={styles.sharepointLoading}>
<FaSpinner className={styles.spinner} /> Lade Ordner...
</div>
) : folderOptions.length === 0 ? (
<div className={styles.sharepointEmpty}>
Keine Unterordner
</div>
) : (
folderOptions.map(folder => (
<div
key={folder.path}
className={styles.sharepointFolderItem}
>
<FaFolder />
<span
className={styles.folderName}
onClick={() => handleFolderNavigate(folder)}
>
{folder.folderName}
</span>
<button
type="button"
className={styles.selectFolderButton}
onClick={() => handleFolderSelect(folder)}
>
Auswählen
</button>
</div>
))
)}
</div>
<button
type="button"
className={styles.selectCurrentFolderButton}
onClick={handleSelectCurrentFolder}
>
Aktuellen Ordner verwenden: {currentPath || '(Root)'}
</button>
</div>
)}
</Popup>
</div>
);
};
@ -715,27 +720,47 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
const isFormSaving = saving || isSaving;
return (
<div className={styles.editorOverlay} onClick={onCancel}>
<div className={styles.editorContainer} onClick={e => e.stopPropagation()}>
{/* Header */}
<header className={styles.editorHeader}>
<div className={styles.headerLeft}>
<h2 className={styles.editorTitle}>{editorTitle}</h2>
<span className={`${styles.modeBadge} ${styles[mode]}`}>
{mode === 'template' ? (
<><FaFileAlt style={{ marginRight: '0.375rem' }} /> Template</>
) : (
<><FaRocket style={{ marginRight: '0.375rem' }} /> Definition</>
)}
</span>
<Popup
isOpen={true}
title={`${editorTitle} · ${mode === 'template' ? 'Template' : 'Definition'}`}
onClose={onCancel}
size="fullscreen"
closable={true}
className={styles.editorPopup}
footerContent={
<div className={styles.editorFooter}>
<div className={styles.footerLeft}>
{jsonError && (
<span style={{ color: 'var(--danger-color)', fontSize: '0.875rem' }}>
<FaExclamationTriangle style={{ marginRight: '0.375rem' }} />
JSON enthält Fehler
</span>
)}
</div>
<div className={styles.headerActions}>
<button className={styles.closeButton} onClick={onCancel} title="Schliessen">
<FaTimes />
<div className={styles.footerRight}>
<button
className={styles.secondaryButton}
onClick={onCancel}
disabled={isFormSaving}
>
Abbrechen
</button>
<button
className={styles.primaryButton}
onClick={handleSave}
disabled={isFormSaving || !!jsonError}
>
{isFormSaving ? (
<>Speichern...</>
) : (
<><FaSave style={{ marginRight: '0.375rem' }} /> Speichern</>
)}
</button>
</div>
</header>
</div>
}
>
<div className={styles.editorContainer}>
{/* Content */}
<div className={styles.editorContent}>
{/* Form Panel (Left) */}
@ -1036,40 +1061,8 @@ export const AutomationEditor: React.FC<AutomationEditorProps> = ({
)}
</div>
</div>
{/* Footer */}
<footer className={styles.editorFooter}>
<div className={styles.footerLeft}>
{jsonError && (
<span style={{ color: 'var(--danger-color)', fontSize: '0.875rem' }}>
<FaExclamationTriangle style={{ marginRight: '0.375rem' }} />
JSON enthält Fehler
</span>
)}
</div>
<div className={styles.footerRight}>
<button
className={styles.secondaryButton}
onClick={onCancel}
disabled={isFormSaving}
>
Abbrechen
</button>
<button
className={styles.primaryButton}
onClick={handleSave}
disabled={isFormSaving || !!jsonError}
>
{isFormSaving ? (
<>Speichern...</>
) : (
<><FaSave style={{ marginRight: '0.375rem' }} /> Speichern</>
)}
</button>
</div>
</footer>
</div>
</div>
</Popup>
);
};

View file

@ -30,8 +30,8 @@ import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/r
// Chat Playground Views (reusing existing workflow pages)
import { PlaygroundPage, WorkflowsPage } from './workflows';
// Automation Views (reusing existing workflow pages)
import { AutomationsPage, AutomationTemplatesPage } from './workflows';
// Automation Views
import { AutomationDefinitionsView, AutomationTemplatesView, AutomationLogsView } from './views/automation';
// CodeEditor Views
import { CodeEditorPage, CodeEditorWorkflowsPage } from './views/codeeditor';
@ -129,9 +129,9 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
workflows: WorkflowsPage,
},
automation: {
definitions: AutomationsPage,
templates: AutomationTemplatesPage,
logs: () => <PlaceholderView title="Execution Logs" description="Automatisierungs-Ausführungsprotokolle" />,
definitions: AutomationDefinitionsView,
templates: AutomationTemplatesView,
logs: AutomationLogsView,
},
codeeditor: {
editor: CodeEditorPage,

View file

@ -1,21 +1,19 @@
/**
* AutomationsPage
*
* Page for viewing and managing workflow automations using FormGeneratorTable.
* AutomationDefinitionsView
*
* View for viewing and managing workflow automation definitions.
* Includes template selection, execution modal with live logs, and execution history.
*/
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { AutomationEditor } from '../../components/AutomationEditor';
import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../../hooks/useAutomations';
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
import { AutomationEditor } from '../../../components/AutomationEditor';
import { FaSync, FaRobot, FaRocket, FaPlus, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi';
import { useFeatureStore } from '../../stores/featureStore';
import styles from '../admin/Admin.module.css';
import { useToast } from '../../../contexts/ToastContext';
import { useApiRequest } from '../../../hooks/useApi';
import { useFeatureStore } from '../../../stores/featureStore';
import styles from '../../admin/Admin.module.css';
interface WorkflowLog {
id: string;
@ -25,17 +23,13 @@ interface WorkflowLog {
progress?: number;
}
export const AutomationsPage: React.FC = () => {
// Get mandate and feature instance from store (first chatbot instance or first available)
export const AutomationDefinitionsView: React.FC = () => {
const { getAllInstances } = useFeatureStore();
const instances = getAllInstances();
// Find first chatbot instance, or fall back to first available instance
const chatbotInstance = instances.find(i => i.featureCode === 'chatbot') || instances[0];
const mandateId = chatbotInstance?.mandateId;
const featureInstanceId = chatbotInstance?.id;
// Data hook
const {
data: automations,
attributes,
@ -47,8 +41,7 @@ export const AutomationsPage: React.FC = () => {
fetchAutomationById,
updateOptimistically,
} = useAutomations();
// Operations hook
const {
handleAutomationCreate,
handleAutomationUpdate,
@ -63,17 +56,12 @@ export const AutomationsPage: React.FC = () => {
const { showSuccess, showError, showInfo } = useToast();
const { request } = useApiRequest();
// Editor states
const [showEditor, setShowEditor] = useState(false);
const [editingAutomation, setEditingAutomation] = useState<Automation | null>(null);
const [editorSaving, setEditorSaving] = useState(false);
// Template selection states
const [showTemplateModal, setShowTemplateModal] = useState(false);
const [templates, setTemplates] = useState<AutomationTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
// Execution modal state
const [executionModal, setExecutionModal] = useState<{
visible: boolean;
automationId: string | null;
@ -89,8 +77,6 @@ export const AutomationsPage: React.FC = () => {
status: 'starting',
logs: [],
});
// Logs modal state
const [logsModal, setLogsModal] = useState<{
visible: boolean;
automation: Automation | null;
@ -99,41 +85,24 @@ export const AutomationsPage: React.FC = () => {
automation: null,
});
// Refs for polling
const pollIntervalRef = useRef<NodeJS.Timeout | null>(null);
const lastLogIdRef = useRef<string | null>(null);
const logContainerRef = useRef<HTMLDivElement>(null);
// Initial fetch
useEffect(() => {
refetch();
useEffect(() => { refetch(); }, []);
useEffect(() => () => {
if (pollIntervalRef.current) clearInterval(pollIntervalRef.current);
}, []);
// Cleanup polling on unmount
useEffect(() => {
return () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
}
};
}, []);
// Auto-scroll logs
useEffect(() => {
if (logContainerRef.current) {
logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}
if (logContainerRef.current) logContainerRef.current.scrollTop = logContainerRef.current.scrollHeight;
}, [executionModal.logs]);
// Generate columns from attributes - exclude internal fields, add enriched display columns
const columns = useMemo(() => {
const hiddenColumns = [
'id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt',
'template', 'executionLogs', 'placeholders',
// Hide enriched fields from attribute list (added manually below)
'_createdByUserName', 'mandateName', 'featureInstanceName',
];
const attrColumns = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => ({
@ -147,63 +116,45 @@ export const AutomationsPage: React.FC = () => {
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
}));
// Add enriched display columns (from backend enrichment)
const enrichedColumns = [
{ key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
{ key: 'featureInstanceName', label: 'Feature-Instanz', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 160, minWidth: 100, maxWidth: 250 },
{ key: '_createdByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 },
];
return [...attrColumns, ...enrichedColumns];
}, [attributes]);
// Check permissions
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
// Handle edit click - open editor with automation data
const handleEditClick = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
setEditingAutomation(fullAutomation as Automation || automation);
setShowEditor(true);
};
// Handle create click - open editor for new automation
const handleCreateClick = () => {
// Pre-fill with context
const newAutomation: Partial<Automation> = {
mandateId: mandateId,
featureInstanceId: featureInstanceId,
setEditingAutomation({
mandateId: mandateId!,
featureInstanceId: featureInstanceId!,
label: '',
schedule: '0 22 * * *',
active: false,
placeholders: {},
};
setEditingAutomation(newAutomation as Automation);
} as Automation);
setShowEditor(true);
};
// Handle editor save
const handleEditorSave = async (data: Partial<Automation>) => {
// Validate context
if (!mandateId || !featureInstanceId) {
showError('Fehler: Kein aktiver Mandant oder Feature-Instanz gefunden');
return;
}
setEditorSaving(true);
try {
// Add required context fields
const saveData = {
...data,
mandateId: mandateId,
featureInstanceId: featureInstanceId,
};
const saveData = { ...data, mandateId, featureInstanceId };
if (editingAutomation?.id) {
// Update existing - include id in payload for backend validation
saveData.id = editingAutomation.id;
const success = await handleAutomationUpdate(editingAutomation.id, saveData as any);
if (success) {
@ -213,7 +164,6 @@ export const AutomationsPage: React.FC = () => {
await refetch();
}
} else {
// Create new
const result = await handleAutomationCreate(saveData as any);
if (result) {
showSuccess('Automatisierung erstellt');
@ -229,13 +179,11 @@ export const AutomationsPage: React.FC = () => {
}
};
// Handle editor cancel
const handleEditorCancel = () => {
setShowEditor(false);
setEditingAutomation(null);
};
// Handle delete single automation (confirmation handled by DeleteActionButton)
const handleDelete = async (automation: Automation) => {
const success = await handleAutomationDelete(automation.id);
if (success) {
@ -244,7 +192,6 @@ export const AutomationsPage: React.FC = () => {
}
};
// Handle duplicate automation
const handleDuplicate = async (automation: Automation) => {
try {
await request({ url: `/api/automations/${automation.id}/duplicate`, method: 'post' });
@ -255,7 +202,6 @@ export const AutomationsPage: React.FC = () => {
}
};
// Load templates
const handleLoadTemplates = async () => {
setLoadingTemplates(true);
try {
@ -273,33 +219,22 @@ export const AutomationsPage: React.FC = () => {
}
};
// Handle template selection - open editor with template data pre-filled
const handleTemplateSelect = (template: AutomationTemplate) => {
setShowTemplateModal(false);
// Validate context - mandateId and featureInstanceId are required
if (!mandateId || !featureInstanceId) {
showError('Fehler: Kein aktiver Mandant oder Feature-Instanz gefunden');
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';
}
templateLabel = typeof template.label === 'string'
? template.label
: (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
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])
const convertedPlaceholders: Record<string, string> = {};
const templateParams = (template as any).parameters || {};
for (const [key, value] of Object.entries(templateParams)) {
@ -311,95 +246,64 @@ export const AutomationsPage: React.FC = () => {
convertedPlaceholders[key] = String(value);
}
}
// Pre-fill form with template data and open editor for user to customize
const prefillData: Partial<Automation> = {
mandateId: mandateId,
featureInstanceId: featureInstanceId,
mandateId,
featureInstanceId,
label: templateLabel,
template: typeof template.template === 'string'
? template.template
: JSON.stringify(template.template, null, 2),
template: typeof template.template === 'string' ? template.template : JSON.stringify(template.template, null, 2),
placeholders: convertedPlaceholders,
active: false,
schedule: '0 22 * * *',
};
// Open editor with pre-filled data (no id = create mode)
setEditingAutomation(prefillData as Automation);
setShowEditor(true);
};
// Poll workflow logs
const pollWorkflowLogs = useCallback(async (workflowId: string) => {
try {
// Include mandate context header for RBAC (featureInstanceId is not needed for workflows)
const contextHeaders: Record<string, string> = {};
if (mandateId) contextHeaders['X-Mandate-Id'] = mandateId;
const response = await request({
url: `/api/workflows/${workflowId}/logs`,
method: 'get',
params: lastLogIdRef.current ? { afterId: lastLogIdRef.current } : {},
additionalConfig: { headers: contextHeaders },
});
const logs: WorkflowLog[] = response?.items || response || [];
if (logs.length > 0) {
setExecutionModal(prev => {
// Deduplicate logs by ID
const existingIds = new Set(prev.logs.map(l => l.id));
const newLogs = logs.filter(l => !existingIds.has(l.id));
return {
...prev,
logs: [...prev.logs, ...newLogs],
};
return { ...prev, logs: [...prev.logs, ...newLogs] };
});
lastLogIdRef.current = logs[logs.length - 1].id;
}
// Check workflow status
const statusResponse = await request({
url: `/api/workflows/${workflowId}`,
method: 'get',
additionalConfig: { headers: contextHeaders },
});
const workflowStatus = statusResponse?.status;
if (workflowStatus === 'completed' || workflowStatus === 'stopped' || workflowStatus === 'error' || workflowStatus === 'failed') {
// Stop polling
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
setExecutionModal(prev => ({
...prev,
status: workflowStatus === 'completed' ? 'completed' :
workflowStatus === 'error' || workflowStatus === 'failed' ? 'error' : 'stopped',
status: workflowStatus === 'completed' ? 'completed' : workflowStatus === 'error' || workflowStatus === 'failed' ? 'error' : 'stopped',
}));
if (workflowStatus === 'completed') {
showSuccess('Automatisierung erfolgreich abgeschlossen');
} else if (workflowStatus === 'error' || workflowStatus === 'failed') {
showError('Automatisierung fehlgeschlagen');
} else {
showInfo('Automatisierung gestoppt');
}
if (workflowStatus === 'completed') showSuccess('Automatisierung erfolgreich abgeschlossen');
else if (workflowStatus === 'error' || workflowStatus === 'failed') showError('Automatisierung fehlgeschlagen');
else showInfo('Automatisierung gestoppt');
refetch();
}
} catch (err) {
console.error('Error polling workflow logs:', err);
}
}, [request, refetch, showSuccess, showError, showInfo, mandateId, featureInstanceId]);
}, [request, refetch, showSuccess, showError, showInfo, mandateId]);
// Handle execute automation with modal
const handleExecute = async (automation: Automation) => {
// Reset and show modal
lastLogIdRef.current = null;
setExecutionModal({
visible: true,
@ -407,78 +311,49 @@ export const AutomationsPage: React.FC = () => {
automationLabel: automation.label,
workflowId: null,
status: 'starting',
logs: [{
id: 'init',
timestamp: Date.now() / 1000,
message: 'Automatisierung wird gestartet...',
}],
logs: [{ id: 'init', timestamp: Date.now() / 1000, message: 'Automatisierung wird gestartet...' }],
});
try {
const result = await handleAutomationExecute(automation.id);
const workflowId = result?.id;
if (workflowId) {
setExecutionModal(prev => ({
...prev,
workflowId,
status: 'running',
logs: [...prev.logs, {
id: 'started',
timestamp: Date.now() / 1000,
message: `Workflow ${workflowId} gestartet`,
status: 'running',
}],
logs: [...prev.logs, { id: 'started', timestamp: Date.now() / 1000, message: `Workflow ${workflowId} gestartet`, status: 'running' }],
}));
// Start polling
pollIntervalRef.current = setInterval(() => {
pollWorkflowLogs(workflowId);
}, 2000);
pollIntervalRef.current = setInterval(() => pollWorkflowLogs(workflowId), 2000);
}
} catch (err: any) {
setExecutionModal(prev => ({
...prev,
status: 'error',
logs: [...prev.logs, {
id: 'error',
timestamp: Date.now() / 1000,
message: `Fehler: ${err.message || 'Unbekannter Fehler'}`,
status: 'error',
}],
logs: [...prev.logs, { id: 'error', timestamp: Date.now() / 1000, message: `Fehler: ${err.message || 'Unbekannter Fehler'}`, status: 'error' }],
}));
showError(`Fehler beim Ausführen: ${err.message}`);
}
};
// Handle stop workflow
const handleStopWorkflow = async () => {
if (!executionModal.workflowId) return;
try {
const stopHeaders: Record<string, string> = {};
if (mandateId) stopHeaders['X-Mandate-Id'] = mandateId;
await request({
url: `/api/workflows/${executionModal.workflowId}/stop`,
method: 'post',
additionalConfig: { headers: stopHeaders },
});
setExecutionModal(prev => ({
...prev,
logs: [...prev.logs, {
id: 'stopping',
timestamp: Date.now() / 1000,
message: 'Workflow wird gestoppt...',
}],
logs: [...prev.logs, { id: 'stopping', timestamp: Date.now() / 1000, message: 'Workflow wird gestoppt...' }],
}));
} catch (err: any) {
showError(`Fehler beim Stoppen: ${err.message}`);
}
};
// Close execution modal
const closeExecutionModal = () => {
if (pollIntervalRef.current) {
clearInterval(pollIntervalRef.current);
@ -494,44 +369,30 @@ export const AutomationsPage: React.FC = () => {
});
};
// Show logs modal
const handleShowLogs = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
setLogsModal({
visible: true,
automation: fullAutomation as Automation || automation,
});
setLogsModal({ visible: true, automation: fullAutomation as Automation || automation });
};
// Format timestamp
const formatTimestamp = (timestamp: number) => {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleString('de-DE');
return new Date(timestamp * 1000).toLocaleString('de-DE');
};
// Format time only
const formatTime = (timestamp: number) => {
if (!timestamp) return '';
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('de-DE');
return new Date(timestamp * 1000).toLocaleTimeString('de-DE');
};
// Get status icon
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <FaCheck className={styles.successIcon} />;
case 'completed': return <FaCheck className={styles.successIcon} />;
case 'error':
case 'failed':
return <FaExclamationCircle className={styles.errorIcon} />;
case 'failed': return <FaExclamationCircle className={styles.errorIcon} />;
case 'running':
case 'starting':
return <FaSpinner className={`${styles.spinningIcon} spinning`} />;
case 'stopped':
return <FaStop className={styles.warningIcon} />;
default:
return null;
case 'starting': return <FaSpinner className={`${styles.spinningIcon} spinning`} />;
case 'stopped': return <FaStop className={styles.warningIcon} />;
default: return null;
}
};
@ -541,9 +402,7 @@ export const AutomationsPage: React.FC = () => {
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Automatisierungen: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
<button className={styles.secondaryButton} onClick={() => refetch()}><FaSync /> Erneut versuchen</button>
</div>
</div>
);
@ -557,26 +416,15 @@ export const AutomationsPage: React.FC = () => {
<p className={styles.pageSubtitle}>Geplante und automatisierte Workflows</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<button className={styles.secondaryButton} onClick={() => refetch()} disabled={loading}>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<>
<button
className={styles.secondaryButton}
onClick={handleLoadTemplates}
disabled={loadingTemplates}
>
<button className={styles.secondaryButton} onClick={handleLoadTemplates} disabled={loadingTemplates}>
<FaFileAlt /> {loadingTemplates ? 'Lädt...' : 'Aus Vorlage'}
</button>
<button
className={styles.primaryButton}
onClick={handleCreateClick}
>
<button className={styles.primaryButton} onClick={handleCreateClick}>
<FaPlus /> Neue Automatisierung
</button>
</>
@ -594,23 +442,11 @@ export const AutomationsPage: React.FC = () => {
<div className={styles.emptyState}>
<FaRobot className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Automatisierungen vorhanden</h3>
<p className={styles.emptyDescription}>
Erstellen Sie eine neue Automatisierung, um Workflows zeitgesteuert auszuführen.
</p>
<p className={styles.emptyDescription}>Erstellen Sie eine neue Automatisierung, um Workflows zeitgesteuert auszuführen.</p>
{canCreate && (
<div className={styles.emptyActions}>
<button
className={styles.secondaryButton}
onClick={handleLoadTemplates}
>
<FaFileAlt /> Aus Vorlage erstellen
</button>
<button
className={styles.primaryButton}
onClick={handleCreateClick}
>
<FaPlus /> Manuell erstellen
</button>
<button className={styles.secondaryButton} onClick={handleLoadTemplates}><FaFileAlt /> Aus Vorlage erstellen</button>
<button className={styles.primaryButton} onClick={handleCreateClick}><FaPlus /> Manuell erstellen</button>
</div>
)}
</div>
@ -627,52 +463,21 @@ export const AutomationsPage: React.FC = () => {
sortable={true}
selectable={false}
actionButtons={[
...(canCreate ? [{
type: 'copy' as const,
title: 'Duplizieren',
onAction: handleDuplicate,
}] : []),
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: any) => deletingAutomations.has(row.id),
}] : []),
...(canCreate ? [{ type: 'copy' as const, title: 'Duplizieren', onAction: handleDuplicate }] : []),
...(canUpdate ? [{ type: 'edit' as const, onAction: handleEditClick, title: 'Bearbeiten' }] : []),
...(canDelete ? [{ type: 'delete' as const, title: 'Löschen', loading: (row: any) => deletingAutomations.has(row.id) }] : []),
]}
customActions={[
{
id: 'execute',
icon: <FaRocket />,
onClick: handleExecute,
title: 'Ausführen',
loading: (row: any) => executingAutomations.has(row.id),
},
{
id: 'logs',
icon: <FaList />,
onClick: handleShowLogs,
title: 'Ausführungsverlauf',
},
{ id: 'execute', icon: <FaRocket />, onClick: handleExecute, title: 'Ausführen', loading: (row: any) => executingAutomations.has(row.id) },
{ id: 'logs', icon: <FaList />, onClick: handleShowLogs, title: 'Ausführungsverlauf' },
]}
onDelete={handleDelete}
hookData={{
refetch,
permissions,
pagination,
handleDelete: handleAutomationDelete,
handleInlineUpdate,
updateOptimistically,
}}
hookData={{ refetch, permissions, pagination, handleDelete: handleAutomationDelete, handleInlineUpdate, updateOptimistically }}
emptyMessage="Keine Automatisierungen gefunden"
/>
)}
</div>
{/* Automation Editor */}
{showEditor && editingAutomation && (
<AutomationEditor
mode="definition"
@ -683,85 +488,46 @@ export const AutomationsPage: React.FC = () => {
/>
)}
{/* Template Selection Modal */}
{showTemplateModal && (
<div className={styles.modalOverlay} onClick={() => setShowTemplateModal(false)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Vorlage auswählen</h2>
<button className={styles.modalClose} onClick={() => setShowTemplateModal(false)}>
<FaTimes />
</button>
<button className={styles.modalClose} onClick={() => setShowTemplateModal(false)}><FaTimes /></button>
</div>
<div className={styles.modalContent}>
<div className={styles.templateList}>
{templates.map((template, index) => {
// Get label from TextMultilingual
const labelText = template.label
? (typeof template.label === 'string'
? template.label
: (template.label as any).de || (template.label as any).en || `Vorlage ${index + 1}`)
: `Vorlage ${index + 1}`;
// 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
const labelText = template.label ? (typeof template.label === 'string' ? template.label : (template.label as any).de || (template.label as any).en || `Vorlage ${index + 1}`) : `Vorlage ${index + 1}`;
const overviewText = template.overview ? (typeof template.overview === 'string' ? template.overview : (template.overview as any).de || (template.overview as any).en || '') : '';
let parsedTemplate: any = null;
try {
if (template.template) {
parsedTemplate = typeof template.template === 'string'
? JSON.parse(template.template)
: template.template;
}
} catch { /* ignore parse errors */ }
const description = overviewText
|| parsedTemplate?.overview
|| parsedTemplate?.tasks?.[0]?.objective
|| 'Keine Beschreibung';
if (template.template) parsedTemplate = typeof template.template === 'string' ? JSON.parse(template.template) : template.template;
} catch { /* ignore */ }
const description = overviewText || parsedTemplate?.overview || parsedTemplate?.tasks?.[0]?.objective || 'Keine Beschreibung';
return (
<div key={template.id || index} className={styles.templateItem}>
<div className={styles.templateHeader}>
<h4 className={styles.templateTitle}>{labelText}</h4>
</div>
<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>
<button className={styles.primaryButton} onClick={() => handleTemplateSelect(template)}><FaCheck /> Verwenden</button>
</div>
);
})}
</div>
</div>
<div className={styles.modalFooter}>
<button className={styles.secondaryButton} onClick={() => setShowTemplateModal(false)}>
Abbrechen
</button>
<button className={styles.secondaryButton} onClick={() => setShowTemplateModal(false)}>Abbrechen</button>
</div>
</div>
</div>
)}
{/* Execution Modal */}
{executionModal.visible && (
<div className={styles.modalOverlay} onClick={closeExecutionModal}>
<div className={styles.modal} onClick={e => e.stopPropagation()} style={{ maxWidth: '700px' }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{getStatusIcon(executionModal.status)} Ausführung: {executionModal.automationLabel}
</h2>
<button className={styles.modalClose} onClick={closeExecutionModal}>
<FaTimes />
</button>
<h2 className={styles.modalTitle}>{getStatusIcon(executionModal.status)} Ausführung: {executionModal.automationLabel}</h2>
<button className={styles.modalClose} onClick={closeExecutionModal}><FaTimes /></button>
</div>
<div className={styles.modalContent}>
<div className={styles.executionStatus}>
@ -772,83 +538,49 @@ export const AutomationsPage: React.FC = () => {
{executionModal.status === 'stopped' && 'Gestoppt'}
{executionModal.status === 'error' && 'Fehler'}
</span>
{executionModal.workflowId && (
<span className={styles.workflowId}>
Workflow: <code>{executionModal.workflowId}</code>
</span>
)}
{executionModal.workflowId && <span className={styles.workflowId}>Workflow: <code>{executionModal.workflowId}</code></span>}
</div>
<div
ref={logContainerRef}
className={styles.executionLogs}
style={{ maxHeight: '400px', overflowY: 'auto', fontFamily: 'monospace', fontSize: '0.875rem' }}
>
<div ref={logContainerRef} className={styles.executionLogs} style={{ maxHeight: '400px', overflowY: 'auto', fontFamily: 'monospace', fontSize: '0.875rem' }}>
{executionModal.logs.map((log, index) => (
<div key={log.id || index} className={`${styles.logEntry} ${log.status === 'error' || log.status === 'failed' ? styles.logEntryError : ''}`}>
<span className={styles.logTime}>[{formatTime(log.timestamp)}]</span>
{log.status && <span className={styles.logStatus}><strong>{log.status}:</strong></span>}
<span className={styles.logMessage}>{log.message}</span>
{log.progress !== undefined && log.progress !== null && log.progress < 1 && (
<span className={styles.logProgress}>({Math.round(log.progress * 100)}%)</span>
)}
{log.progress !== undefined && log.progress !== null && log.progress < 1 && <span className={styles.logProgress}>({Math.round(log.progress * 100)}%)</span>}
</div>
))}
</div>
</div>
<div className={styles.modalFooter}>
{executionModal.status === 'running' && (
<button className={styles.dangerButton} onClick={handleStopWorkflow}>
<FaStop /> Stoppen
</button>
)}
<button className={styles.secondaryButton} onClick={closeExecutionModal}>
Schliessen
</button>
{executionModal.status === 'running' && <button className={styles.dangerButton} onClick={handleStopWorkflow}><FaStop /> Stoppen</button>}
<button className={styles.secondaryButton} onClick={closeExecutionModal}>Schliessen</button>
</div>
</div>
</div>
)}
{/* Logs History Modal */}
{logsModal.visible && logsModal.automation && (
<div className={styles.modalOverlay} onClick={() => setLogsModal({ visible: false, automation: null })}>
<div className={styles.modal} onClick={e => e.stopPropagation()} style={{ maxWidth: '700px' }}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
Ausführungsverlauf: {logsModal.automation.label}
</h2>
<button
className={styles.modalClose}
onClick={() => setLogsModal({ visible: false, automation: null })}
>
<FaTimes />
</button>
<h2 className={styles.modalTitle}>Ausführungsverlauf: {logsModal.automation.label}</h2>
<button className={styles.modalClose} onClick={() => setLogsModal({ visible: false, automation: null })}><FaTimes /></button>
</div>
<div className={styles.modalContent}>
{(!logsModal.automation.executionLogs || logsModal.automation.executionLogs.length === 0) ? (
<div className={styles.emptyState}>
<p>Keine Ausführungen vorhanden</p>
</div>
<div className={styles.emptyState}><p>Keine Ausführungen vorhanden</p></div>
) : (
<div className={styles.logsHistory}>
{[...logsModal.automation.executionLogs].reverse().map((log, index) => (
<div key={index} className={`${styles.logHistoryItem} ${styles[log.status || 'unknown']}`}>
<div className={styles.logHistoryHeader}>
<span className={styles.logHistoryDate}>{formatTimestamp(log.timestamp)}</span>
<span className={`${styles.statusBadge} ${styles[log.status || 'unknown']}`}>
{log.status || 'Unbekannt'}
</span>
{log.workflowId && (
<span className={styles.workflowId}>
Workflow: <code>{log.workflowId}</code>
</span>
)}
<span className={`${styles.statusBadge} ${styles[log.status || 'unknown']}`}>{log.status || 'Unbekannt'}</span>
{log.workflowId && <span className={styles.workflowId}>Workflow: <code>{log.workflowId}</code></span>}
</div>
{log.messages && log.messages.length > 0 && (
<div className={styles.logHistoryMessages}>
{log.messages.map((msg, msgIndex) => (
<div key={msgIndex} className={styles.logHistoryMessage}>{msg}</div>
))}
{log.messages.map((msg, msgIndex) => <div key={msgIndex} className={styles.logHistoryMessage}>{msg}</div>)}
</div>
)}
</div>
@ -857,12 +589,7 @@ export const AutomationsPage: React.FC = () => {
)}
</div>
<div className={styles.modalFooter}>
<button
className={styles.secondaryButton}
onClick={() => setLogsModal({ visible: false, automation: null })}
>
Schliessen
</button>
<button className={styles.secondaryButton} onClick={() => setLogsModal({ visible: false, automation: null })}>Schliessen</button>
</div>
</div>
</div>
@ -870,5 +597,3 @@ export const AutomationsPage: React.FC = () => {
</div>
);
};
export default AutomationsPage;

View file

@ -0,0 +1,14 @@
/**
* AutomationLogsView
*
* Placeholder view for automation execution logs.
*/
import React from 'react';
import styles from '../../FeatureView.module.css';
export const AutomationLogsView: React.FC = () => (
<div className={styles.placeholder}>
<h2>Execution Logs</h2>
<p>Automatisierungs-Ausführungsprotokolle</p>
</div>
);

View file

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

View file

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

View file

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

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