automation template/definition editor

This commit is contained in:
ValueOn AG 2026-02-03 23:42:19 +01:00
parent 8d86c166d0
commit fe5f4bf188
7 changed files with 2232 additions and 248 deletions

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

@ -72,6 +72,8 @@ interface Connection {
status: string;
externalUsername?: string;
accountName?: string; // Legacy fallback
connectionReference?: string; // Backend computed field: connection:{authority}:{username}
displayLabel?: string; // Backend computed field: human-readable label
}
interface ExistingAutomation {
@ -181,10 +183,10 @@ export const TrusteeExpenseImportView: React.FC = () => {
// Format: "connection:msft:externalUsername" or just the connection ID
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 =>
c.connectionReference === savedConnectionRef ||
savedConnectionRef.includes(c.externalUsername || '') ||
savedConnectionRef.includes(c.accountName || '') ||
savedConnectionRef.includes(c.id)
);
@ -196,6 +198,11 @@ export const TrusteeExpenseImportView: React.FC = () => {
}
}, [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
const loadSiteOptions = useCallback(async () => {
if (!msftConnection) return;
@ -204,7 +211,9 @@ export const TrusteeExpenseImportView: React.FC = () => {
setError(null);
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 || []);
} catch (err: any) {
console.error('Failed to load sites:', err);
@ -213,7 +222,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
} finally {
setIsLoadingSites(false);
}
}, [msftConnection]);
}, [msftConnection, getConnectionReference]);
// Load folders when site is selected
const loadFolderOptions = useCallback(async (siteId: string, path: string = '') => {
@ -223,10 +232,11 @@ export const TrusteeExpenseImportView: React.FC = () => {
setError(null);
try {
const params = new URLSearchParams({ siteId });
const connectionRef = getConnectionReference(msftConnection);
const params = new URLSearchParams({ connectionReference: connectionRef, siteId });
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 || []);
} catch (err: any) {
console.error('Failed to load folders:', err);
@ -235,7 +245,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
} finally {
setIsLoadingFolders(false);
}
}, [msftConnection]);
}, [msftConnection, getConnectionReference]);
useEffect(() => {
if (msftConnection) {
@ -509,7 +519,7 @@ export const TrusteeExpenseImportView: React.FC = () => {
<option value="">Select a Microsoft account...</option>
{msftConnections.map((conn) => (
<option key={conn.id} value={conn.id}>
{conn.accountName || conn.id}
{conn.displayLabel || conn.externalUsername || conn.id}
</option>
))}
</select>

View file

@ -50,11 +50,11 @@ export const TrusteePositionsView: React.FC = () => {
}, [instanceId]);
// 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(() => {
return (attributes || [])
const attrColumns = (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => ({
key: attr.name,
@ -67,6 +67,21 @@ export const TrusteePositionsView: React.FC = () => {
minWidth: attr.minWidth || 100,
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]);
// Check permissions

View file

@ -2,14 +2,14 @@
* AutomationTemplatesPage
*
* Page for managing automation templates (CRUD).
* Uses FormGeneratorTable for listing and FormGeneratorForm for editing.
* 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 { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaPlus, FaTimes, FaFileAlt } from 'react-icons/fa';
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';
@ -29,9 +29,10 @@ export const AutomationTemplatesPage: React.FC = () => {
const { showSuccess, showError } = useToast();
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false);
// Editor states
const [showEditor, setShowEditor] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<AutomationTemplate | null>(null);
const [saving, setSaving] = useState(false);
// Initial fetch
useEffect(() => {
@ -50,49 +51,47 @@ export const AutomationTemplatesPage: React.FC = () => {
{ 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
// Handle edit click - open editor with template data
const handleEditClick = async (template: AutomationTemplate) => {
// Fetch full template data
const fullTemplate = await getTemplate(template.id);
if (fullTemplate) {
setEditingTemplate(fullTemplate);
} else {
setEditingTemplate(template);
}
setEditingTemplate(fullTemplate || template);
setShowEditor(true);
};
// 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 create click - open editor for new template
const handleCreateClick = () => {
setEditingTemplate(null);
setShowEditor(true);
};
// Handle edit submit
const handleEditSubmit = async (data: Partial<AutomationTemplate>) => {
if (!editingTemplate) return;
// Handle editor save
const handleEditorSave = async (data: Partial<AutomationTemplate>) => {
setSaving(true);
try {
await updateTemplate(editingTemplate.id, data);
if (editingTemplate) {
await updateTemplate(editingTemplate.id, data);
showSuccess('Vorlage aktualisiert');
} else {
await createTemplate(data as any);
showSuccess('Vorlage erstellt');
}
setShowEditor(false);
setEditingTemplate(null);
showSuccess('Vorlage aktualisiert');
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 {
@ -135,12 +134,12 @@ export const AutomationTemplatesPage: React.FC = () => {
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> Neue Vorlage
</button>
<button
className={styles.primaryButton}
onClick={handleCreateClick}
>
<FaPlus /> Neue Vorlage
</button>
)}
</div>
</div>
@ -161,7 +160,7 @@ export const AutomationTemplatesPage: React.FC = () => {
{canCreate && (
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
onClick={handleCreateClick}
>
<FaPlus /> Vorlage erstellen
</button>
@ -200,67 +199,15 @@ export const AutomationTemplatesPage: React.FC = () => {
)}
</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>
{/* Automation Editor */}
{showEditor && (
<AutomationEditor
mode="template"
initialData={editingTemplate}
onSave={handleEditorSave}
onCancel={handleEditorCancel}
saving={saving}
/>
)}
</div>
);

View file

@ -8,7 +8,7 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations';
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 { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi';
@ -63,9 +63,12 @@ export const AutomationsPage: React.FC = () => {
const { showSuccess, showError, showInfo } = useToast();
const { request } = useApiRequest();
// Modal states
const [showCreateModal, setShowCreateModal] = useState(false);
// 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);
@ -146,46 +149,76 @@ export const AutomationsPage: React.FC = () => {
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
// Handle edit click
// Handle edit click - open editor with automation data
const handleEditClick = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
if (fullAutomation) {
setEditingAutomation(fullAutomation as Automation);
}
setEditingAutomation(fullAutomation as Automation || automation);
setShowEditor(true);
};
// Handle create submit
const handleCreateSubmit = async (data: Partial<Automation>) => {
// Validate context - mandateId and featureInstanceId are required
// Handle create click - open editor for new automation
const handleCreateClick = () => {
// 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) {
showError('Fehler: Kein aktiver Mandant oder Feature-Instanz gefunden');
return;
}
// Add required fields from context
const createData = {
...data,
mandateId: mandateId,
featureInstanceId: featureInstanceId,
};
setEditorSaving(true);
try {
// Add required context fields
const saveData = {
...data,
mandateId: mandateId,
featureInstanceId: featureInstanceId,
};
const result = await handleAutomationCreate(createData as any);
if (result) {
setShowCreateModal(false);
showSuccess('Automatisierung erstellt');
await refetch();
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) {
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
const handleEditSubmit = async (data: Partial<Automation>) => {
if (!editingAutomation) return;
const success = await handleAutomationUpdate(editingAutomation.id, data as any);
if (success) {
setEditingAutomation(null);
showSuccess('Automatisierung aktualisiert');
await refetch();
}
// Handle editor cancel
const handleEditorCancel = () => {
setShowEditor(false);
setEditingAutomation(null);
};
// Handle delete single automation (confirmation handled by DeleteActionButton)
@ -215,8 +248,8 @@ export const AutomationsPage: React.FC = () => {
}
};
// Handle template selection
const handleTemplateSelect = async (template: AutomationTemplate) => {
// Handle template selection - open editor with template data pre-filled
const handleTemplateSelect = (template: AutomationTemplate) => {
setShowTemplateModal(false);
// Validate context - mandateId and featureInstanceId are required
@ -225,39 +258,51 @@ export const AutomationsPage: React.FC = () => {
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])
// Arrays and objects are converted to JSON strings
const convertedPlaceholders: Record<string, string> = {};
const templateParams = template.parameters || {};
const templateParams = (template as any).parameters || {};
for (const [key, value] of Object.entries(templateParams)) {
if (value === null || value === undefined) {
convertedPlaceholders[key] = '';
} else if (Array.isArray(value) || (typeof value === 'object' && value !== null)) {
// Convert complex structures to JSON strings
convertedPlaceholders[key] = JSON.stringify(value);
} else {
// Keep primitive values as strings
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> = {
mandateId: mandateId,
featureInstanceId: featureInstanceId,
label: template.template?.overview || 'Neue Automatisierung',
template: JSON.stringify(template.template, null, 2),
label: templateLabel,
template: typeof template.template === 'string'
? template.template
: JSON.stringify(template.template, null, 2),
placeholders: convertedPlaceholders,
active: false,
schedule: '0 */4 * * *',
schedule: '0 22 * * *',
};
// Create automation directly
const result = await handleAutomationCreate(prefillData as any);
if (result) {
showSuccess('Automatisierung aus Vorlage erstellt');
await refetch();
}
// Open editor with pre-filled data (no id = create mode)
setEditingAutomation(prefillData as Automation);
setShowEditor(true);
};
// 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
const formatTimestamp = (timestamp: number) => {
if (!timestamp) return '';
@ -502,7 +540,7 @@ export const AutomationsPage: React.FC = () => {
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
onClick={handleCreateClick}
>
<FaPlus /> Neue Automatisierung
</button>
@ -534,7 +572,7 @@ export const AutomationsPage: React.FC = () => {
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
onClick={handleCreateClick}
>
<FaPlus /> Manuell erstellen
</button>
@ -593,67 +631,15 @@ export const AutomationsPage: React.FC = () => {
)}
</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 Automatisierung</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 */}
{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>
{/* Automation Editor */}
{showEditor && editingAutomation && (
<AutomationEditor
mode="definition"
initialData={editingAutomation}
onSave={handleEditorSave}
onCancel={handleEditorCancel}
saving={editorSaving}
/>
)}
{/* Template Selection Modal */}
@ -668,26 +654,51 @@ export const AutomationsPage: React.FC = () => {
</div>
<div className={styles.modalContent}>
<div className={styles.templateList}>
{templates.map((template, index) => (
<div key={index} className={styles.templateItem}>
<div className={styles.templateHeader}>
<h4 className={styles.templateTitle}>
{template.template?.overview || `Vorlage ${index + 1}`}
</h4>
{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
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>
<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 className={styles.modalFooter}>