fix: removed duplicate keepAlive Files for pages and views and instead implemented single source of truth for mounted pages, removed unused legacy core page manager

This commit is contained in:
Ida 2026-05-13 14:25:20 +02:00
parent 74dc7b85f8
commit 8860f49714
21 changed files with 182 additions and 1594 deletions

View file

@ -169,8 +169,8 @@ function App() {
<Route path="editor" element={<FeatureViewPage view="editor" />} />
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
{/* Automation2 Workflows & Tasks */}
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
{/* Automation2: legacy workflows URL → editor */}
<Route path="workflows" element={<Navigate to="../editor" replace />} />
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
{/* Teams Bot Feature Views */}

View file

@ -0,0 +1,45 @@
import type { KeepAliveEntry } from '../types/keepAlive.types';
import { AdminLanguagesPage } from '../pages/admin/AdminLanguagesPage';
import { CommcoachSessionView } from '../pages/views/commcoach';
import { GraphicalEditorPage } from '../pages/views/graphicalEditor/GraphicalEditorPage';
import { WorkspacePage } from '../pages/views/workspace/WorkspacePage';
export const KEEP_ALIVE_ROUTES: KeepAliveEntry[] = [
{
id: 'workspace-dashboard',
pathRegex: /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/,
scopeRegex: /\/mandates\/([^/]+)\/workspace\/([^/]+)/,
requireMandateForMount: false,
render: ({ instanceId, scopeKey }) => (
<WorkspacePage key={scopeKey} persistentInstanceId={instanceId} />
),
},
{
id: 'commcoach-session',
pathRegex: /\/mandates\/[^/]+\/commcoach\/[^/]+\/session/,
scopeRegex: /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/session/,
shellOverflowHidden: false,
render: ({ scopeKey }) => <CommcoachSessionView key={scopeKey} />,
},
{
id: 'graphical-editor',
pathRegex: /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/,
scopeRegex: /\/mandates\/([^/]+)\/graphicalEditor\/([^/]+)\/editor/,
render: ({ mandateId, instanceId, scopeKey }) => (
<GraphicalEditorPage
key={scopeKey}
persistentInstanceId={instanceId}
persistentMandateId={mandateId}
/>
),
},
{
id: 'admin-languages',
pathRegex: /\/admin\/languages(?:$|\/)/,
render: () => <AdminLanguagesPage />,
},
];
export function hideFeatureOutlet(pathname: string): boolean {
return KEEP_ALIVE_ROUTES.some((e) => e.pathRegex.test(pathname));
}

View file

@ -131,7 +131,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'feature.chatworkflow': <FaPlay />,
'feature.graphicalEditor': <FaProjectDiagram />,
'page.feature.graphicalEditor.editor': <FaProjectDiagram />,
'page.feature.graphicalEditor.workflows': <FaProjectDiagram />,
'page.feature.graphicalEditor.workflows-tasks': <FaClipboardList />,
'page.feature.chatbot.conversations': <FaComments />,
'feature.chatbot': <FaComments />,

View file

@ -1,473 +0,0 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { allPageData, SidebarItem, SidebarSubmenuItemData } from './data';
import { useLanguage } from '../../providers/language/LanguageContext';
import { resolveLanguageText, GenericPageData } from './pageInterface';
import { usePermissions } from '../../hooks/usePermissions';
import { FaHome, FaHatWizard, FaBriefcase, FaBuilding, FaProjectDiagram } from 'react-icons/fa';
import { RiFolderSettingsFill } from 'react-icons/ri';
// Configuration for parent groups that don't have a page definition
// Maps parentPath (can be nested like "start.real-estate") to icon and default order
const parentGroupConfig: Record<string, {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
defaultOrder?: number;
}> = {
'start': {
icon: FaHome,
defaultOrder: 1
},
'workflows': {
icon: FaProjectDiagram,
defaultOrder: 2
},
'trustee': {
icon: FaBriefcase,
defaultOrder: 3
},
'basedata': {
icon: RiFolderSettingsFill,
defaultOrder: 4
},
'admin': {
icon: FaHatWizard,
defaultOrder: 5
},
'start.realestate': {
icon: FaBuilding,
defaultOrder: 2
}
};
interface SidebarContextType {
sidebarItems: SidebarItem[];
loading: boolean;
error: string | null;
refreshSidebar: () => Promise<void>;
}
const SidebarContext = createContext<SidebarContextType | undefined>(undefined);
export const useSidebar = () => {
const context = useContext(SidebarContext);
if (!context) {
throw new Error('useSidebar must be used within a SidebarProvider');
}
return context;
};
interface SidebarProviderProps {
children: React.ReactNode;
}
export const SidebarProvider: React.FC<SidebarProviderProps> = ({ children }) => {
const [sidebarItems, setSidebarItems] = useState<SidebarItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Get translation function from language context
const { t } = useLanguage();
const { canView, preloadUiPermissions } = usePermissions();
// Helper type for navigation tree nodes
interface NavigationNode {
id: string;
pathSegment: string;
fullPath: string; // Full dot-notation path (e.g., "start.real-estate")
name: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
order: number;
page?: GenericPageData; // If this node represents an actual page
children: Map<string, NavigationNode>; // Keyed by path segment
pages: GenericPageData[]; // Direct child pages
}
// Helper function to resolve node name
const resolveNodeName = (pathSegment: string, _fullPath: string, page?: GenericPageData): string => {
if (page) {
return resolveLanguageText(page.name, t);
}
return pathSegment.split('-').map(s => s.charAt(0).toUpperCase() + s.slice(1)).join(' ');
};
// Helper function to resolve node icon
const resolveNodeIcon = (pathSegment: string, fullPath: string, page?: GenericPageData): React.ComponentType<React.SVGProps<SVGSVGElement>> | undefined => {
if (page?.icon) {
return page.icon;
}
// Check parentGroupConfig for nested paths first (e.g., "start.real-estate")
if (parentGroupConfig[fullPath]?.icon) {
return parentGroupConfig[fullPath].icon;
}
// Check parentGroupConfig for top-level segments (e.g., "start")
if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.icon) {
return parentGroupConfig[pathSegment].icon;
}
return undefined;
};
// Helper function to resolve node order
const resolveNodeOrder = (pathSegment: string, fullPath: string, page?: GenericPageData, childPages: GenericPageData[] = []): number => {
if (page?.order !== undefined) {
return page.order;
}
// Check parentGroupConfig for top-level segments
if (fullPath === pathSegment && parentGroupConfig[pathSegment]?.defaultOrder !== undefined) {
return parentGroupConfig[pathSegment].defaultOrder!;
}
// Use minimum order of child pages
if (childPages.length > 0) {
const childOrders = childPages.map(p => p.order ?? 0);
return Math.min(...childOrders);
}
return 0;
};
// Build navigation tree from page data
const buildNavigationTree = (): Map<string, NavigationNode> => {
const rootNodes = new Map<string, NavigationNode>();
// Process all pages with parent paths
const pagesWithParents = allPageData.filter(
page => page.parentPath && !page.hide && page.showInSidebar !== false
);
for (const page of pagesWithParents) {
if (!page.parentPath) continue;
// Parse parent path segments (e.g., "start.real-estate" -> ["start", "real-estate"])
const pathSegments = page.parentPath.split('.');
// Build path to root, creating nodes as needed
let currentMap = rootNodes;
let currentFullPath = '';
for (let i = 0; i < pathSegments.length; i++) {
const segment = pathSegments[i];
currentFullPath = currentFullPath ? `${currentFullPath}.${segment}` : segment;
// Get or create node for this segment
if (!currentMap.has(segment)) {
// Check if there's a page for this path segment
const segmentPage = allPageData.find(
p => p.path === currentFullPath && !p.hide
);
const node: NavigationNode = {
id: segmentPage?.id || currentFullPath,
pathSegment: segment,
fullPath: currentFullPath,
name: '', // Will be resolved later
icon: undefined, // Will be resolved later
order: 0, // Will be resolved later
page: segmentPage,
children: new Map(),
pages: []
};
currentMap.set(segment, node);
}
const node = currentMap.get(segment)!;
// If this is the last segment, add the page as a child page
if (i === pathSegments.length - 1) {
node.pages.push(page);
}
// Move to next level
currentMap = node.children;
}
}
// Resolve names, icons, and orders for all nodes
const resolveNode = (node: NavigationNode): void => {
// Resolve children first (bottom-up)
for (const childNode of node.children.values()) {
resolveNode(childNode);
}
// Resolve this node
node.name = resolveNodeName(node.pathSegment, node.fullPath, node.page);
node.icon = resolveNodeIcon(node.pathSegment, node.fullPath, node.page);
// Collect all child pages (from direct pages and nested children)
const allChildPages = [...node.pages];
for (const childNode of node.children.values()) {
if (childNode.page) {
allChildPages.push(childNode.page);
}
allChildPages.push(...childNode.pages);
}
node.order = resolveNodeOrder(node.pathSegment, node.fullPath, node.page, allChildPages);
};
// Resolve all root nodes
for (const node of rootNodes.values()) {
resolveNode(node);
}
return rootNodes;
};
// Convert navigation tree node to sidebar submenu item (recursive)
const nodeToSubmenuItem = async (node: NavigationNode, depth: number = 0): Promise<SidebarSubmenuItemData | null> => {
// Filter child pages by RBAC and privilegeChecker
const accessiblePages: GenericPageData[] = [];
for (const page of node.pages) {
try {
const hasRBACAccess = await canView('UI', page.path);
if (!hasRBACAccess) continue;
if (page.privilegeChecker) {
try {
const hasPrivilege = await page.privilegeChecker();
if (!hasPrivilege) continue;
} catch (error) {
console.error(`Error checking privilegeChecker for page ${page.path}:`, error);
continue;
}
}
accessiblePages.push(page);
} catch (error) {
console.error(`Error checking RBAC access for page ${page.path}:`, error);
}
}
// Process child nodes recursively (increment depth)
const accessibleChildren: SidebarSubmenuItemData[] = [];
for (const childNode of node.children.values()) {
const childItem = await nodeToSubmenuItem(childNode, depth + 1);
if (childItem) {
accessibleChildren.push(childItem);
}
}
// Combine pages and child nodes, assigning depth
const allChildren: SidebarSubmenuItemData[] = [
...accessiblePages.map(page => ({
id: page.id,
name: resolveLanguageText(page.name, t),
link: `/${page.path}`,
icon: page.icon,
depth: depth + 1 // Child pages are one level deeper
})),
...accessibleChildren
];
// If no accessible children, don't create this node
if (allChildren.length === 0) {
return null;
}
// If this node has a page itself, it shouldn't be a navigation node
// But according to requirements: if it has subpages, it is NOT a page itself
// So we create a navigation node without a link
return {
id: node.id,
name: node.name,
link: undefined, // Navigation node - not a clickable page
icon: node.icon,
submenu: allChildren.length > 0 ? allChildren : undefined,
depth: depth // Current depth level
};
};
// Convert navigation tree to sidebar items
const treeToSidebarItems = async (tree: Map<string, NavigationNode>): Promise<SidebarItem[]> => {
const items: SidebarItem[] = [];
// Process each root node (depth 0 for top-level items)
for (const node of tree.values()) {
const submenuItem = await nodeToSubmenuItem(node, 0);
if (submenuItem && submenuItem.submenu && submenuItem.submenu.length > 0) {
items.push({
id: node.id,
name: node.name,
link: undefined, // Navigation node - not a clickable page
icon: node.icon,
moduleEnabled: true,
order: node.order,
submenu: submenuItem.submenu,
depth: 0 // Top-level items have depth 0
});
}
}
return items;
};
// Get sidebar items from page data
const getSidebarItems = async (): Promise<SidebarItem[]> => {
const items: SidebarItem[] = [];
// Build navigation tree
const navigationTree = buildNavigationTree();
// Convert tree to sidebar items
const treeItems = await treeToSidebarItems(navigationTree);
items.push(...treeItems);
// Get main pages (no parent path)
const mainPages = allPageData
.filter(page => !page.parentPath && !page.hide && page.showInSidebar !== false)
.sort((a, b) => (a.order || 0) - (b.order || 0));
// Process each main page
for (const pageData of mainPages) {
// Check RBAC permissions
try {
const hasRBACAccess = await canView('UI', pageData.path);
if (!hasRBACAccess) {
continue;
}
// Check client-side privilegeChecker if provided
if (pageData.privilegeChecker) {
try {
const hasPrivilege = await pageData.privilegeChecker();
if (!hasPrivilege) {
continue;
}
} catch (error) {
console.error(`Error checking privilegeChecker for ${pageData.path}:`, error);
continue;
}
}
} catch (error) {
console.error(`Error checking RBAC access for ${pageData.path}:`, error);
continue;
}
// Check if this page has subpages (legacy support)
if (pageData.hasSubpages) {
// Find all subpages for this parent
const allSubpages = allPageData.filter(p =>
p.parentPath === pageData.path &&
!p.hide &&
p.showInSidebar !== false
);
// Filter subpages by RBAC access
const accessibleSubpages: GenericPageData[] = [];
for (const subpage of allSubpages) {
try {
const hasSubpageRBACAccess = await canView('UI', subpage.path);
if (!hasSubpageRBACAccess) {
continue;
}
if (subpage.privilegeChecker) {
try {
const hasPrivilege = await subpage.privilegeChecker();
if (!hasPrivilege) {
continue;
}
} catch (error) {
console.error(`Error checking privilegeChecker for subpage ${subpage.path}:`, error);
continue;
}
}
accessibleSubpages.push(subpage);
} catch (error) {
console.error(`Error checking RBAC access for subpage ${subpage.path}:`, error);
}
}
if (accessibleSubpages.length > 0) {
// Create item with submenu (no link since it has subpages)
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
link: undefined, // No link - has subpages, so it's a navigation node
icon: pageData.icon,
moduleEnabled: pageData.moduleEnabled ?? true,
order: pageData.order || 0,
depth: 0, // Top-level items have depth 0
submenu: accessibleSubpages.map(subpage => ({
id: subpage.id,
name: resolveLanguageText(subpage.name, t),
link: `/${subpage.path}`,
icon: subpage.icon,
depth: 1 // First level of submenu
}))
});
} else {
// No accessible subpages, show as regular item
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
link: `/${pageData.path}`,
icon: pageData.icon,
moduleEnabled: pageData.moduleEnabled ?? true,
order: pageData.order || 0,
depth: 0 // Top-level items have depth 0
});
}
} else {
// Regular items without subpages
items.push({
id: pageData.id,
name: resolveLanguageText(pageData.name, t),
link: `/${pageData.path}`,
icon: pageData.icon,
moduleEnabled: pageData.moduleEnabled ?? true,
order: pageData.order || 0,
depth: 0 // Top-level items have depth 0
});
}
}
// Sort all items by order
const sortedItems = items.sort((a, b) => (a.order || 0) - (b.order || 0));
return sortedItems;
};
// Refresh sidebar items
const refreshSidebar = async () => {
console.log('🔄 SidebarProvider: Refreshing sidebar items...');
setLoading(true);
setError(null);
try {
// Preload all UI permissions in a single API call
// This caches all permissions before iterating through pages
await preloadUiPermissions();
const items = await getSidebarItems();
console.log('✅ SidebarProvider: Setting sidebar items:', {
count: items.length,
items: items.map(item => ({ id: item.id, link: item.link, name: item.name }))
});
setSidebarItems(items);
} catch (err) {
console.error('❌ SidebarProvider: Error refreshing sidebar:', err);
setError(err instanceof Error ? err.message : t('Seitenleiste konnte nicht geladen werden'));
} finally {
setLoading(false);
}
};
// Load sidebar items on mount and when language changes
useEffect(() => {
refreshSidebar();
}, [t]);
const contextValue: SidebarContextType = {
sidebarItems,
loading,
error,
refreshSidebar
};
return (
<SidebarContext.Provider value={contextValue}>
{children}
</SidebarContext.Provider>
);
};
export default SidebarProvider;

View file

@ -1,27 +0,0 @@
/**
* PageManager data: allPageData and SidebarItem type.
*/
import type React from 'react';
export { allPageData } from './pages';
export interface SidebarItem {
id: string;
name: string;
link?: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
moduleEnabled?: boolean;
order?: number;
submenu?: SidebarSubmenuItemData[];
depth?: number;
}
export interface SidebarSubmenuItemData {
id: string;
name: string;
link?: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
depth?: number;
submenu?: SidebarSubmenuItemData[];
}

View file

@ -1,15 +0,0 @@
/**
* Central page registry: all PageData for PageManager/Sidebar.
*/
import type { GenericPageData } from '../../pageInterface';
import { trusteePositionDocumentsPageData } from './trustee/position-documents';
import { realEstatePages } from './realestate';
export { realEstatePages } from './realestate';
export { trusteePositionDocumentsPageData } from './trustee/position-documents';
export const allPageData: GenericPageData[] = [
trusteePositionDocumentsPageData,
...realEstatePages,
];

View file

@ -1,3 +0,0 @@
import { GenericPageData } from '../../../pageInterface';
export const realEstatePages: GenericPageData[] = [];

View file

@ -1,241 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaLink, FaPlus } from 'react-icons/fa';
import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations } from '../../../../../hooks/useTrusteePositionDocuments';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
return {
key: attr.name,
label: attr.label || attr.name,
type: attr.type || 'string',
width: attr.width || 200,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
sortable: attr.sortable !== false,
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
// Hook factory function for position-documents data
const createPositionDocumentsHook = () => {
return () => {
const {
positionDocuments,
loading,
error,
refetch,
removeOptimistically,
attributes,
permissions,
pagination,
fetchPositionDocumentById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteePositionDocuments();
const {
handlePositionDocumentDelete,
handlePositionDocumentCreate,
deletingPositionDocuments,
creatingPositionDocument,
deleteError,
createError
} = useTrusteePositionDocumentOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandlePositionDocumentCreate = useCallback(async (formData: any) => {
return await handlePositionDocumentCreate(formData);
}, [handlePositionDocumentCreate]);
const handleDeleteSingle = useCallback(async (positionDocument: any) => {
const success = await handlePositionDocumentDelete(positionDocument.id);
if (success) {
refetch();
}
}, [handlePositionDocumentDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedPositionDocuments: any[]) => {
const positionDocumentIds = selectedPositionDocuments.map(pd => pd.id);
const results = await Promise.all(
positionDocumentIds.map(id => handlePositionDocumentDelete(id))
);
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handlePositionDocumentDelete, refetch]);
return {
data: positionDocuments,
loading,
error,
refetch,
removeOptimistically,
handleDelete: handlePositionDocumentDelete,
handleDeleteMultiple,
handlePositionDocumentCreate: wrappedHandlePositionDocumentCreate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingPositionDocuments,
creatingPositionDocument,
deleteError,
createError,
attributes,
permissions,
columns: generatedColumns,
pagination,
fetchPositionDocumentById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteePositionDocumentsPageData: GenericPageData = {
id: 'administration-trustee-position-documents',
path: 'administration/trustee/position-documents',
name: 'trustee.positionDocuments.title',
description: 'trustee.positionDocuments.description',
// Parent page
parentPath: 'administration/trustee',
// Visual
icon: FaLink,
title: 'trustee.positionDocuments.title',
subtitle: 'trustee.positionDocuments.subtitle',
// Header buttons
headerButtons: [
{
id: 'new-position-document',
label: 'trustee.positionDocuments.new',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'organisationId',
label: 'trustee.positionDocuments.field.organisationId',
type: 'enum',
required: true,
optionsReference: 'trustee.organisation',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Organisation is required';
}
return null;
}
},
{
key: 'contractId',
label: 'trustee.positionDocuments.field.contractId',
type: 'enum',
required: true,
optionsReference: 'trustee.contract',
dependsOn: 'organisationId',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Contract is required';
}
return null;
}
},
{
key: 'positionId',
label: 'trustee.positionDocuments.field.positionId',
type: 'enum',
required: true,
optionsReference: 'trustee.position',
dependsOn: 'contractId',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Position is required';
}
return null;
}
},
{
key: 'documentId',
label: 'trustee.positionDocuments.field.documentId',
type: 'enum',
required: true,
optionsReference: 'trustee.document',
dependsOn: 'contractId',
validator: (value: any) => {
if (!value || (typeof value === 'string' && value.trim() === '')) {
return 'Document is required';
}
return null;
}
}
],
popupTitle: 'trustee.positionDocuments.modal.create.title',
popupSize: 'medium',
createOperationName: 'handlePositionDocumentCreate',
successMessage: 'trustee.positionDocuments.create.success',
errorMessage: 'trustee.positionDocuments.create.error'
}
}
],
// Content sections
content: [
{
id: 'position-documents-table',
type: 'table',
tableConfig: {
hookFactory: createPositionDocumentsHook,
actionButtons: [
{
type: 'delete',
title: 'trustee.positionDocuments.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingPositionDocuments',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasDelete = hookData.permissions.delete !== 'n' && hookData.permissions.view;
return { disabled: !hasDelete, message: 'No permission to delete links' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'position-documents-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Position-Documents activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Position-Documents loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Position-Documents unloaded');
}
};

View file

@ -1,44 +0,0 @@
/**
* PageManager page interface and helpers.
* Used by PageData definitions and SidebarProvider.
*/
import type React from 'react';
export interface GenericPageData {
id: string;
path: string;
name: string;
description?: string;
parentPath?: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
title?: string;
subtitle?: string;
headerButtons?: Array<Record<string, unknown>>;
content?: Array<Record<string, unknown>>;
moduleEnabled?: boolean;
order?: number;
hide?: boolean;
showInSidebar?: boolean;
showInSidebarIf?: boolean;
hasSubpages?: boolean;
privilegeChecker?: () => Promise<boolean> | boolean;
persistent?: boolean;
preload?: boolean;
preserveState?: boolean;
onActivate?: () => void | Promise<void>;
onLoad?: () => void | Promise<void>;
onUnload?: () => void | Promise<void>;
}
type TranslationFunction = (key: string, params?: Record<string, string | number | boolean | null | undefined>) => string;
/**
* Resolve display text from a page name (i18n key) via the translation function.
*/
export function resolveLanguageText(
name: string,
t: TranslationFunction
): string {
return t(name);
}

View file

@ -5,39 +5,103 @@
* Enthält den FeatureProvider für das Multi-Tenant-System.
*/
import React, { useEffect, useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
import { UserSection } from '../components/Navigation/UserSection';
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive';
import { KEEP_ALIVE_ROUTES, hideFeatureOutlet } from '../config/keepAliveRoutes';
import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types';
import { isKeepAliveScoped } from '../types/keepAlive.types';
import styles from './MainLayout.module.css';
import { useLanguage } from '../providers/language/LanguageContext';
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/session/;
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
const _ADMIN_LANGUAGES_RE = /\/admin\/languages(?:$|\/)/;
const keepAliveShellStyle = (isVisible: boolean, shellOverflowHidden: boolean): React.CSSProperties => ({
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
...(shellOverflowHidden ? { overflow: 'hidden' as const } : {}),
});
const RoutedKeepAliveUnscoped: React.FC<{ entry: KeepAliveUnscopedEntry; pathname: string }> = ({
entry,
pathname,
}) => {
const isVisible = entry.pathRegex.test(pathname);
return (
<div style={keepAliveShellStyle(isVisible, true)}>
{entry.render()}
</div>
);
};
const RoutedKeepAliveScoped: React.FC<{ entry: KeepAliveScopedEntry; pathname: string }> = ({
entry,
pathname,
}) => {
const isVisible = entry.pathRegex.test(pathname);
const {
scopeRegex,
requireMandateForMount = true,
shellOverflowHidden = true,
render,
} = entry;
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const match = pathname.match(scopeRegex);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
}
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
const scopeReady = requireMandateForMount
? !!(mandateId && instanceId)
: !!instanceId;
if (!scopeReady) {
return null;
}
const scopeKey = `${mandateId}:${instanceId}`;
return (
<div style={keepAliveShellStyle(isVisible, shellOverflowHidden)}>
{render({ mandateId, instanceId, scopeKey })}
</div>
);
};
const RoutedKeepAliveSlot: React.FC<{ entry: KeepAliveEntry; pathname: string }> = ({
entry,
pathname,
}) => {
if (!isKeepAliveScoped(entry)) {
return <RoutedKeepAliveUnscoped entry={entry} pathname={pathname} />;
}
return <RoutedKeepAliveScoped entry={entry} pathname={pathname} />;
};
// =============================================================================
// INNER LAYOUT (mit Zugriff auf Store)
// =============================================================================
const MainLayoutInner: React.FC = () => {
const { t } = useLanguage();
const { t } = useLanguage();
const { loadFeatures, initialized, loading, error } = useFeatureStore();
const location = useLocation();
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname);
const isCommcoachKeepAliveVisible = _COMMCOACH_ROUTE_RE.test(location.pathname);
const isGEEditorKeepAliveVisible = _GE_EDITOR_ROUTE_RE.test(location.pathname);
const isLanguagesKeepAliveVisible = _ADMIN_LANGUAGES_RE.test(location.pathname);
const hideOutletShell = isWorkspaceKeepAliveVisible || isCommcoachKeepAliveVisible || isGEEditorKeepAliveVisible || isLanguagesKeepAliveVisible;
const hideOutletShell = hideFeatureOutlet(location.pathname);
// Features laden beim Mount
useEffect(() => {
@ -74,19 +138,11 @@ const MainLayoutInner: React.FC = () => {
{/* Sidebar */}
<aside className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''}`}>
<div className={styles.logoContainer}>
<img
src="/logos/poweron-logo.png"
alt="PowerOn"
className={styles.logoImage}
/>
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.logoImage} />
</div>
<nav className={styles.navigation}>
{loading && (
<div className={styles.loadingNav}>
{t('Lade Navigation…')}
</div>
)}
{loading && <div className={styles.loadingNav}>{t('Lade Navigation…')}</div>}
{error && (
<div className={styles.errorNav}>
@ -94,9 +150,7 @@ const MainLayoutInner: React.FC = () => {
</div>
)}
{initialized && !loading && (
<MandateNavigation />
)}
{initialized && !loading && <MandateNavigation />}
</nav>
{/* User-Bereich am unteren Rand */}
@ -113,17 +167,12 @@ const MainLayoutInner: React.FC = () => {
>
</button>
<img
src="/logos/poweron-logo.png"
alt="PowerOn"
className={styles.mobileLogo}
/>
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.mobileLogo} />
</div>
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
<GraphicalEditorKeepAlive isVisible={isGEEditorKeepAliveVisible} />
<AdminLanguagesKeepAlive isVisible={isLanguagesKeepAliveVisible} />
{KEEP_ALIVE_ROUTES.map((routeEntry) => (
<RoutedKeepAliveSlot key={routeEntry.id} entry={routeEntry} pathname={location.pathname} />
))}
<div
className={styles.outletShell}

View file

@ -6,6 +6,8 @@
*/
import React from 'react';
import { useLocation } from 'react-router-dom';
import { hideFeatureOutlet } from '../config/keepAliveRoutes';
import { useCurrentInstance } from '../hooks/useCurrentInstance';
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
// Trustee Views
@ -28,7 +30,6 @@ import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/r
// GraphicalEditor Views
import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage';
import { GraphicalEditorWorkflowsPage } from './views/graphicalEditor/GraphicalEditorWorkflowsPage';
import { GraphicalEditorWorkflowsTasksPage } from './views/graphicalEditor/GraphicalEditorWorkflowsTasksPage';
import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage';
// Workspace Views
@ -148,7 +149,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
},
graphicalEditor: {
editor: GraphicalEditorPage,
workflows: GraphicalEditorWorkflowsPage,
'workflows-tasks': GraphicalEditorWorkflowsTasksPage,
templates: GraphicalEditorTemplatesPage,
},
@ -192,6 +192,7 @@ interface FeatureViewPageProps {
}
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
const location = useLocation();
const { instance, featureCode, isValid } = useCurrentInstance();
// Berechtigungs-Check
@ -227,19 +228,9 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
return <AccessDenied />;
}
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
// other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering.
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor' && view !== 'rag-insights') {
return null;
}
// CommCoach session is rendered persistently by CommcoachKeepAlive at MainLayout level.
if (featureCode === 'commcoach' && view === 'session') {
return null;
}
// GraphicalEditor editor is rendered persistently by GraphicalEditorKeepAlive at MainLayout level.
if (featureCode === 'graphicalEditor' && view === 'editor') {
// Feature outlet is hidden for paths configured in KEEP_ALIVE_ROUTES (rendered in MainLayout).
// Add new persistent workspace URLs there if needed.
if (hideFeatureOutlet(location.pathname)) {
return null;
}

View file

@ -1,35 +0,0 @@
/**
* AdminLanguagesKeepAlive
*
* Keeps the AdminLanguagesPage mounted across route changes so that
* long-running AI translation progress, table state, and selections
* survive when the user navigates away and returns.
*/
import React from 'react';
import { AdminLanguagesPage } from './AdminLanguagesPage';
interface AdminLanguagesKeepAliveProps {
isVisible: boolean;
}
export const AdminLanguagesKeepAlive: React.FC<AdminLanguagesKeepAliveProps> = ({ isVisible }) => {
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<AdminLanguagesPage />
</div>
);
};
export default AdminLanguagesKeepAlive;

View file

@ -1,55 +0,0 @@
/**
* CommcoachKeepAlive
*
* Keeps the CommCoach session page mounted across route changes.
* The voice session must persist when the user navigates to other tabs.
* Only the "session" tab is kept alive; modules/dashboard can unmount freely.
*
* Persistence is scoped per `(mandateId, instanceId)` switching to a
* different mandate or instance unmounts the previous view.
*/
import React, { useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { CommcoachSessionView } from './CommcoachSessionView';
const _COMMCOACH_SESSION_ROUTE_RE = /\/mandates\/([^/]+)\/commcoach\/([^/]+)\/session/;
interface CommcoachKeepAliveProps {
isVisible: boolean;
}
export const CommcoachKeepAlive: React.FC<CommcoachKeepAliveProps> = ({ isVisible }) => {
const location = useLocation();
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const match = location.pathname.match(_COMMCOACH_SESSION_ROUTE_RE);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
}
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
if (!mandateId || !instanceId) return null;
const scopeKey = `${mandateId}:${instanceId}`;
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
}}
>
<CommcoachSessionView key={scopeKey} />
</div>
);
};
export default CommcoachKeepAlive;

View file

@ -1,70 +0,0 @@
/**
* GraphicalEditorKeepAlive
*
* Keeps the GraphicalEditorPage mounted across route changes so the canvas
* state, SSE connections, and editor context survive navigation to ANY page
* (other features, admin, settings, etc.).
*
* Persistence is scoped per `(mandateId, instanceId)`: when the user switches
* to a DIFFERENT mandate or instance via the navigator, the previous editor
* mount is discarded and a fresh page is mounted. Otherwise stale state from
* mandate A leaks into mandate B and saves end up hitting the wrong tenant
* (HTTP 404 / "not found").
*
* Implementation: feeds the cached `(mandate, instance)` tuple into both
* `props` and `key`. React reuses the mount as long as the tuple stays
* identical and unmounts/remounts on change.
*/
import React, { useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { GraphicalEditorPage } from './GraphicalEditorPage';
const _GE_EDITOR_ROUTE_RE = /\/mandates\/([^/]+)\/graphicalEditor\/([^/]+)\/editor/;
interface GraphicalEditorKeepAliveProps {
isVisible: boolean;
}
export const GraphicalEditorKeepAlive: React.FC<GraphicalEditorKeepAliveProps> = ({ isVisible }) => {
const location = useLocation();
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const hasEverMountedRef = useRef(false);
const match = location.pathname.match(_GE_EDITOR_ROUTE_RE);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
hasEverMountedRef.current = true;
}
if (!hasEverMountedRef.current) return null;
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
const scopeKey = `${mandateId}:${instanceId}`;
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<GraphicalEditorPage
key={scopeKey}
persistentInstanceId={instanceId}
persistentMandateId={mandateId}
/>
</div>
);
};
export default GraphicalEditorKeepAlive;

View file

@ -1,441 +0,0 @@
/**
* GraphicalEditorWorkflowsPage
* List of saved workflows with FormGeneratorTable.
* Shows: label, active, isRunning, stuckAt, createdAt, lastStartedAt, runCount.
* Filter: Alle | Aktiv | Inaktiv.
* Actions: Edit, Delete, Aktivieren/Deaktivieren, Ausführen (nur bei manuellem Trigger).
*/
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { FaPlay, FaSync, FaCheck, FaBan, FaPen, FaFileImport, FaFileExport } from 'react-icons/fa';
import { usePrompt } from '../../../hooks/usePrompt';
import { FormGeneratorTable, type ColumnConfig } from '../../../components/FormGenerator';
import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useApiRequest } from '../../../hooks/useApi';
import {
fetchWorkflows,
deleteWorkflow,
executeGraph,
updateWorkflow,
importWorkflowFromFile,
exportWorkflowToFile,
isWorkflowFileContent,
workflowFileNameFor,
WORKFLOW_FILE_EXTENSION,
type Automation2Workflow,
type WorkflowFileEnvelope,
} from '../../../api/workflowApi';
import { fetchAttributes } from '../../../api/attributesApi';
import type { AttributeDefinition } from '../../../api/attributesApi';
import { resolveColumnTypes } from '../../../utils/columnTypeResolver';
import { useToast } from '../../../contexts/ToastContext';
import { formatUnixTimestamp } from '../../../utils/time';
import styles from '../../../pages/admin/Admin.module.css';
import { useLanguage } from '../../../providers/language/LanguageContext';
function formatTs(ts?: number): string {
if (ts == null || ts <= 0) return '—';
const sec = ts < 1e12 ? ts : ts / 1000;
const { time } = formatUnixTimestamp(sec, undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
return time;
}
export const GraphicalEditorWorkflowsPage: React.FC = () => {
const { t } = useLanguage();
const instanceId = useInstanceId();
const { mandateId } = useParams<{ mandateId: string }>();
const { request } = useApiRequest();
const navigate = useNavigate();
const { showSuccess, showError } = useToast();
const { prompt: promptInput, PromptDialog } = usePrompt();
const [workflows, setWorkflows] = useState<Automation2Workflow[]>([]);
const [loading, setLoading] = useState(true);
const [executingId, setExecutingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [activeFilter, setActiveFilter] = useState<'all' | 'active' | 'inactive'>('all');
const [paginationMeta, setPaginationMeta] = useState<any>(null);
const [importing, setImporting] = useState(false);
const importFileInputRef = useRef<HTMLInputElement>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
useEffect(() => {
fetchAttributes(request, 'Automation2WorkflowView')
.then(setBackendAttributes)
.catch((err) => {
console.error('[graphicalEditor] fetchAttributes Automation2WorkflowView failed', err);
});
}, [request]);
const load = useCallback(async (paginationParams?: any) => {
if (!instanceId) return;
setLoading(true);
try {
const active = activeFilter === 'active' ? true : activeFilter === 'inactive' ? false : undefined;
const result = await fetchWorkflows(request, instanceId, { active, pagination: paginationParams });
if (result && typeof result === 'object' && 'items' in result && !Array.isArray(result)) {
setWorkflows((result as any).items);
setPaginationMeta((result as any).pagination);
} else {
setWorkflows(result as Automation2Workflow[]);
setPaginationMeta(null);
}
} catch (e) {
console.error('[graphicalEditor] load workflows failed', e);
showError(t('Fehler beim Laden der Workflows'));
} finally {
setLoading(false);
}
}, [instanceId, request, showError, activeFilter, t]);
useEffect(() => {
load();
}, [load]);
const handleDelete = useCallback(
async (workflowId: string): Promise<boolean> => {
if (!instanceId) return false;
try {
await deleteWorkflow(request, instanceId, workflowId);
showSuccess(t('Workflow gelöscht'));
await load();
return true;
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Löschen fehlgeschlagen') }));
return false;
}
},
[instanceId, request, showSuccess, showError, load, t]
);
const handleEdit = useCallback(
(row: Automation2Workflow) => {
if (!mandateId || !instanceId) return;
navigate(`/mandates/${mandateId}/graphicalEditor/${instanceId}/editor?workflowId=${row.id}`);
},
[mandateId, instanceId, navigate]
);
const hasManualTrigger = useCallback((row: Automation2Workflow): boolean => {
const invs = row.invocations || [];
return invs.some((i) => i.enabled && (i.kind === 'manual' || i.kind === 'api'));
}, []);
const handleToggleActive = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
const next = !(row.active !== false);
setTogglingId(row.id);
try {
await updateWorkflow(request, instanceId, row.id, { active: next });
showSuccess(next ? t('Workflow aktiviert') : t('Workflow deaktiviert'));
await load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Status-Update fehlgeschlagen') }));
} finally {
setTogglingId(null);
}
},
[instanceId, request, showSuccess, showError, load, t]
);
const handleRename = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
const newLabel = await promptInput(t('Neuer Name:'), {
title: t('Workflow umbenennen'),
defaultValue: row.label,
placeholder: t('Workflow-Name'),
});
if (!newLabel || newLabel.trim() === row.label) return;
try {
await updateWorkflow(request, instanceId, row.id, { label: newLabel.trim() });
showSuccess(t('Workflow umbenannt'));
await load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Umbenennen fehlgeschlagen') }));
}
},
[instanceId, request, promptInput, showSuccess, showError, load, t]
);
const handleExecute = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
setExecutingId(row.id);
try {
const invs = row.invocations || [];
const primary =
invs.find((i) => i.enabled && i.kind === 'manual') ||
invs.find((i) => i.enabled && (i.kind === 'form' || i.kind === 'api'));
const result = await executeGraph(request, instanceId, row.graph!, row.id, {
...(primary ? { entryPointId: primary.id } : {}),
});
if (result?.success) {
if (result?.paused) {
showSuccess(t('Workflow gestartet und bei Human Task pausiert. Öffne Workflows & Tasks.'));
} else {
showSuccess(t('Workflow ausgeführt'));
}
await load();
} else {
showError(result?.error || t('Ausführung fehlgeschlagen'));
}
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Ausführung fehlgeschlagen') }));
} finally {
setExecutingId(null);
}
},
[instanceId, request, showSuccess, showError, load, t]
);
const handleExport = useCallback(
async (row: Automation2Workflow) => {
if (!instanceId) return;
try {
const result = await exportWorkflowToFile(request, instanceId, row.id, false);
const fileName = result.fileName || workflowFileNameFor(row.label);
const blob = new Blob([JSON.stringify(result.envelope, null, 2)], {
type: 'application/json;charset=utf-8',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
showSuccess(t('Workflow als Datei exportiert'));
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Export fehlgeschlagen') }));
}
},
[instanceId, request, showSuccess, showError, t],
);
const handleImportFileSelected = useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file || !instanceId) return;
setImporting(true);
try {
const text = await file.text();
let envelope: WorkflowFileEnvelope;
try {
envelope = JSON.parse(text) as WorkflowFileEnvelope;
} catch {
showError(t('Datei ist kein gültiges JSON'));
return;
}
if (!isWorkflowFileContent(envelope)) {
showError(t('Datei ist kein PowerOn-Workflow ({ext})', { ext: WORKFLOW_FILE_EXTENSION }));
return;
}
const result = await importWorkflowFromFile(request, instanceId, { envelope });
const warnings = result?.warnings ?? [];
if (warnings.length > 0) {
showSuccess(
t('Workflow importiert ({n} Warnungen). Aktivierung manuell.', { n: warnings.length }),
);
} else {
showSuccess(t('Workflow importiert (deaktiviert). Bitte vor Aktivierung prüfen.'));
}
await load();
} catch (e: any) {
showError(t('Fehler: {msg}', { msg: e?.message || t('Import fehlgeschlagen') }));
} finally {
setImporting(false);
}
},
[instanceId, request, showSuccess, showError, load, t],
);
const _rawColumns: ColumnConfig[] = useMemo(() => [
{ key: 'label', label: t('Workflow'), width: 200, sortable: true, filterable: true },
{ key: 'active', width: 80, sortable: true, filterable: true },
{ key: 'isRunning', width: 80, sortable: true, filterable: true },
{
key: 'stuckAtNodeLabel',
label: t('steht bei'),
width: 160,
sortable: false,
filterable: false,
formatter: (value: string, row: Automation2Workflow) =>
row.isRunning && (value || row.stuckAtNodeId)
? value || row.stuckAtNodeId || '—'
: '—',
},
{
key: 'sysCreatedAt',
label: t('Erstellt'),
width: 140,
sortable: true,
filterable: true,
formatter: (v: number) => formatTs(v),
},
{
key: 'lastStartedAt',
label: t('zuletzt gestartet'),
width: 160,
sortable: true,
filterable: true,
formatter: (v: number) => formatTs(v),
},
{
key: 'runCount',
label: t('Läufe'),
width: 80,
sortable: true,
filterable: true,
formatter: (v: number) => (v != null ? String(v) : '0'),
},
], [t]);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, backendAttributes),
[_rawColumns, backendAttributes],
);
const hookData = {
refetch: load,
handleDelete: (id: string) => handleDelete(id),
pagination: paginationMeta,
};
if (!instanceId) {
return (
<div className={styles.adminPage}>
<p>{t('Keine Feature-Instanz gefunden')}</p>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<p className={styles.pageSubtitle}>
{t('Workflows verwalten, ausführen und bearbeiten')}
</p>
</div>
<div className={styles.headerActions} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 4 }}>
{(['all', 'active', 'inactive'] as const).map((f) => (
<button
key={f}
className={activeFilter === f ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveFilter(f)}
disabled={loading}
>
{f === 'all' ? t('Alle') : f === 'active' ? t('Aktiv') : t('Inaktiv')}
</button>
))}
</div>
<button
className={styles.secondaryButton}
onClick={() => importFileInputRef.current?.click()}
disabled={importing || loading}
title={t('Workflow aus Datei importieren ({ext})', { ext: WORKFLOW_FILE_EXTENSION })}
>
<FaFileImport /> {importing ? t('Importiere...') : t('Importieren')}
</button>
<input
ref={importFileInputRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={handleImportFileSelected}
/>
<button
className={styles.secondaryButton}
onClick={() => load()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable<Automation2Workflow>
data={workflows}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
apiEndpoint={`/api/workflows/${instanceId}/workflows`}
actionButtons={[
{
type: 'edit',
title: t('bearbeiten'),
onAction: handleEdit,
},
{
type: 'delete',
title: t('löschen'),
},
]}
customActions={[
{
id: 'rename',
icon: <FaPen />,
title: t('umbenennen'),
onClick: (row) => handleRename(row),
},
{
id: 'activate',
icon: <FaCheck />,
title: t('aktivieren'),
onClick: (row) => handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.active === false,
},
{
id: 'deactivate',
icon: <FaBan />,
title: t('deaktivieren'),
onClick: (row) => handleToggleActive(row),
loading: (row) => togglingId === row.id,
visible: (row) => row.active !== false,
},
{
id: 'execute',
icon: <FaPlay />,
title: t('ausführen'),
onClick: (row) => handleExecute(row),
loading: (row) => executingId === row.id,
visible: (row) => hasManualTrigger(row),
},
{
id: 'export',
icon: <FaFileExport />,
title: t('Als Datei exportieren'),
onClick: (row) => handleExport(row),
},
]}
onDelete={(row) => handleDelete(row.id)}
hookData={hookData}
emptyMessage={t('Keine Workflows gefunden. Erstelle einen.')}
/>
</div>
<PromptDialog />
</div>
);
};

View file

@ -76,7 +76,7 @@ function hasManualOrFormInvocation(wf: Automation2Workflow): boolean {
}
/**
* Primary entry for execute matches GraphicalEditorWorkflowsPage.handleExecute
* Primary entry for execute POST /api/workflows/{instanceId}/execute with collected inputs.
* (manual first, then form or api).
*/
function getPrimaryEntryPoint(wf: Automation2Workflow) {

View file

@ -1,57 +0,0 @@
/**
* WorkspaceKeepAlive
*
* Renders the WorkspacePage permanently at the MainLayout level so it
* survives route changes. Visibility is toggled via CSS `display`
* instead of mount / unmount, preserving messages, SSE connections,
* files, and all other workspace state.
*
* Persistence is scoped per `(mandateId, instanceId)` switching to a
* different mandate or instance via the navigator unmounts the previous
* page and mounts a fresh one (otherwise stale state from tenant A
* leaks into tenant B).
*/
import React, { useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { WorkspacePage } from './WorkspacePage';
const _WORKSPACE_ROUTE_RE = /\/mandates\/([^/]+)\/workspace\/([^/]+)/;
interface WorkspaceKeepAliveProps {
isVisible: boolean;
}
export const WorkspaceKeepAlive: React.FC<WorkspaceKeepAliveProps> = ({ isVisible }) => {
const location = useLocation();
const cachedMandateIdRef = useRef<string>('');
const cachedInstanceIdRef = useRef<string>('');
const match = location.pathname.match(_WORKSPACE_ROUTE_RE);
if (match?.[1] && match?.[2]) {
cachedMandateIdRef.current = match[1];
cachedInstanceIdRef.current = match[2];
}
const mandateId = cachedMandateIdRef.current;
const instanceId = cachedInstanceIdRef.current;
if (!instanceId) return null;
const scopeKey = `${mandateId}:${instanceId}`;
return (
<div
style={{
display: isVisible ? 'flex' : 'none',
flexDirection: 'column',
position: 'absolute',
top: 'var(--mobile-topbar-height, 0px)',
left: 0,
right: 0,
bottom: 0,
overflow: 'hidden',
}}
>
<WorkspacePage key={scopeKey} persistentInstanceId={instanceId} />
</div>
);
};

View file

@ -1,45 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Vitest global setup: jest-dom matchers + jsdom polyfills required by some
// of our components (ResizeObserver, matchMedia, scrollIntoView).
import '@testing-library/jest-dom/vitest';
import { afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
afterEach(() => {
cleanup();
});
class _ResizeObserverPolyfill {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
if (!('ResizeObserver' in globalThis)) {
(globalThis as unknown as { ResizeObserver: typeof _ResizeObserverPolyfill }).ResizeObserver =
_ResizeObserverPolyfill;
}
if (!('matchMedia' in window)) {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
}
if (!('scrollIntoView' in HTMLElement.prototype)) {
(HTMLElement.prototype as unknown as { scrollIntoView: () => void }).scrollIntoView =
function (): void {};
}

View file

@ -1,23 +0,0 @@
// Copyright (c) 2025 Patrick Motsch
// All rights reserved.
//
// Smoke test that validates the Vitest + jsdom setup is wired correctly.
// If this fails the rest of the suite is meaningless.
import { describe, expect, it } from 'vitest';
describe('vitest smoke', () => {
it('runs in jsdom and has window/document', () => {
expect(typeof window).toBe('object');
expect(typeof document).toBe('object');
});
it('has jest-dom matchers via globals setup', () => {
const div = document.createElement('div');
div.textContent = 'hello';
document.body.appendChild(div);
expect(div).toBeInTheDocument();
expect(div).toHaveTextContent('hello');
document.body.removeChild(div);
});
});

View file

@ -0,0 +1,34 @@
import type { ReactNode } from 'react';
export interface KeepAliveRenderContext {
mandateId: string;
instanceId: string;
scopeKey: string;
}
/** Mandate-scoped persistent routes: cache (mandateId, instanceId) from the URL while hidden. */
export interface KeepAliveScopedEntry {
id: string;
pathRegex: RegExp;
scopeRegex: RegExp;
/**
* If false, mount once instanceId is known (Workspace). If true, both ids required (Commcoach, Graphical Editor).
*/
requireMandateForMount?: boolean;
/** Commcoach shell omits overflow:hidden; other routes use hidden. */
shellOverflowHidden?: boolean;
render: (ctx: KeepAliveRenderContext) => ReactNode;
}
/** Routes kept alive without mandate/instance scope (e.g. admin). */
export interface KeepAliveUnscopedEntry {
id: string;
pathRegex: RegExp;
render: () => ReactNode;
}
export type KeepAliveEntry = KeepAliveScopedEntry | KeepAliveUnscopedEntry;
export function isKeepAliveScoped(entry: KeepAliveEntry): entry is KeepAliveScopedEntry {
return 'scopeRegex' in entry;
}

View file

@ -259,7 +259,6 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
icon: 'sitemap',
views: [
{ code: 'editor', label: 'Editor', path: 'editor' },
{ code: 'workflows', label: 'Workflows', path: 'workflows' },
{ code: 'templates', label: 'Vorlagen', path: 'templates' },
{ code: 'workflows-tasks', label: 'Tasks', path: 'workflows-tasks' },
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },