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:
parent
74dc7b85f8
commit
8860f49714
21 changed files with 182 additions and 1594 deletions
|
|
@ -169,8 +169,8 @@ function App() {
|
||||||
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
<Route path="editor" element={<FeatureViewPage view="editor" />} />
|
||||||
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
|
<Route path="rag-insights" element={<FeatureViewPage view="rag-insights" />} />
|
||||||
|
|
||||||
{/* Automation2 Workflows & Tasks */}
|
{/* Automation2: legacy workflows URL → editor */}
|
||||||
<Route path="workflows" element={<FeatureViewPage view="workflows" />} />
|
<Route path="workflows" element={<Navigate to="../editor" replace />} />
|
||||||
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
|
<Route path="workflows-tasks" element={<FeatureViewPage view="workflows-tasks" />} />
|
||||||
|
|
||||||
{/* Teams Bot Feature Views */}
|
{/* Teams Bot Feature Views */}
|
||||||
|
|
|
||||||
45
src/config/keepAliveRoutes.tsx
Normal file
45
src/config/keepAliveRoutes.tsx
Normal 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));
|
||||||
|
}
|
||||||
|
|
@ -131,7 +131,6 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'feature.chatworkflow': <FaPlay />,
|
'feature.chatworkflow': <FaPlay />,
|
||||||
'feature.graphicalEditor': <FaProjectDiagram />,
|
'feature.graphicalEditor': <FaProjectDiagram />,
|
||||||
'page.feature.graphicalEditor.editor': <FaProjectDiagram />,
|
'page.feature.graphicalEditor.editor': <FaProjectDiagram />,
|
||||||
'page.feature.graphicalEditor.workflows': <FaProjectDiagram />,
|
|
||||||
'page.feature.graphicalEditor.workflows-tasks': <FaClipboardList />,
|
'page.feature.graphicalEditor.workflows-tasks': <FaClipboardList />,
|
||||||
'page.feature.chatbot.conversations': <FaComments />,
|
'page.feature.chatbot.conversations': <FaComments />,
|
||||||
'feature.chatbot': <FaComments />,
|
'feature.chatbot': <FaComments />,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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[];
|
|
||||||
}
|
|
||||||
|
|
@ -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,
|
|
||||||
];
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
import { GenericPageData } from '../../../pageInterface';
|
|
||||||
|
|
||||||
export const realEstatePages: GenericPageData[] = [];
|
|
||||||
|
|
@ -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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
|
|
@ -5,39 +5,103 @@
|
||||||
* Enthält den FeatureProvider für das Multi-Tenant-System.
|
* 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 { Outlet, useLocation } from 'react-router-dom';
|
||||||
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
|
import { FeatureProvider, useFeatureStore } from '../stores/featureStore';
|
||||||
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
import { MandateNavigation } from '../components/Navigation/MandateNavigation';
|
||||||
import { UserSection } from '../components/Navigation/UserSection';
|
import { UserSection } from '../components/Navigation/UserSection';
|
||||||
import { WorkspaceKeepAlive } from '../pages/views/workspace/WorkspaceKeepAlive';
|
import { KEEP_ALIVE_ROUTES, hideFeatureOutlet } from '../config/keepAliveRoutes';
|
||||||
import { CommcoachKeepAlive } from '../pages/views/commcoach/CommcoachKeepAlive';
|
import type { KeepAliveEntry, KeepAliveScopedEntry, KeepAliveUnscopedEntry } from '../types/keepAlive.types';
|
||||||
import { GraphicalEditorKeepAlive } from '../pages/views/graphicalEditor/GraphicalEditorKeepAlive';
|
import { isKeepAliveScoped } from '../types/keepAlive.types';
|
||||||
import { AdminLanguagesKeepAlive } from '../pages/admin/AdminLanguagesKeepAlive';
|
|
||||||
import styles from './MainLayout.module.css';
|
import styles from './MainLayout.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
|
|
||||||
const _WORKSPACE_ROUTE_RE = /\/mandates\/[^/]+\/workspace\/[^/]+\/dashboard/;
|
const keepAliveShellStyle = (isVisible: boolean, shellOverflowHidden: boolean): React.CSSProperties => ({
|
||||||
const _COMMCOACH_ROUTE_RE = /\/mandates\/[^/]+\/commcoach\/[^/]+\/session/;
|
display: isVisible ? 'flex' : 'none',
|
||||||
const _GE_EDITOR_ROUTE_RE = /\/mandates\/[^/]+\/graphicalEditor\/[^/]+\/editor/;
|
flexDirection: 'column',
|
||||||
const _ADMIN_LANGUAGES_RE = /\/admin\/languages(?:$|\/)/;
|
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)
|
// INNER LAYOUT (mit Zugriff auf Store)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
const MainLayoutInner: React.FC = () => {
|
const MainLayoutInner: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
const { loadFeatures, initialized, loading, error } = useFeatureStore();
|
const { loadFeatures, initialized, loading, error } = useFeatureStore();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
|
||||||
const isWorkspaceKeepAliveVisible = _WORKSPACE_ROUTE_RE.test(location.pathname);
|
const hideOutletShell = hideFeatureOutlet(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;
|
|
||||||
|
|
||||||
// Features laden beim Mount
|
// Features laden beim Mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -74,19 +138,11 @@ const MainLayoutInner: React.FC = () => {
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''}`}>
|
<aside className={`${styles.sidebar} ${isMobileSidebarOpen ? styles.sidebarOpen : ''}`}>
|
||||||
<div className={styles.logoContainer}>
|
<div className={styles.logoContainer}>
|
||||||
<img
|
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.logoImage} />
|
||||||
src="/logos/poweron-logo.png"
|
|
||||||
alt="PowerOn"
|
|
||||||
className={styles.logoImage}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className={styles.navigation}>
|
<nav className={styles.navigation}>
|
||||||
{loading && (
|
{loading && <div className={styles.loadingNav}>{t('Lade Navigation…')}</div>}
|
||||||
<div className={styles.loadingNav}>
|
|
||||||
{t('Lade Navigation…')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className={styles.errorNav}>
|
<div className={styles.errorNav}>
|
||||||
|
|
@ -94,9 +150,7 @@ const MainLayoutInner: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{initialized && !loading && (
|
{initialized && !loading && <MandateNavigation />}
|
||||||
<MandateNavigation />
|
|
||||||
)}
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* User-Bereich am unteren Rand */}
|
{/* User-Bereich am unteren Rand */}
|
||||||
|
|
@ -113,17 +167,12 @@ const MainLayoutInner: React.FC = () => {
|
||||||
>
|
>
|
||||||
☰
|
☰
|
||||||
</button>
|
</button>
|
||||||
<img
|
<img src="/logos/poweron-logo.png" alt="PowerOn" className={styles.mobileLogo} />
|
||||||
src="/logos/poweron-logo.png"
|
|
||||||
alt="PowerOn"
|
|
||||||
className={styles.mobileLogo}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WorkspaceKeepAlive isVisible={isWorkspaceKeepAliveVisible} />
|
{KEEP_ALIVE_ROUTES.map((routeEntry) => (
|
||||||
<CommcoachKeepAlive isVisible={isCommcoachKeepAliveVisible} />
|
<RoutedKeepAliveSlot key={routeEntry.id} entry={routeEntry} pathname={location.pathname} />
|
||||||
<GraphicalEditorKeepAlive isVisible={isGEEditorKeepAliveVisible} />
|
))}
|
||||||
<AdminLanguagesKeepAlive isVisible={isLanguagesKeepAliveVisible} />
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.outletShell}
|
className={styles.outletShell}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { hideFeatureOutlet } from '../config/keepAliveRoutes';
|
||||||
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
import { useCurrentInstance } from '../hooks/useCurrentInstance';
|
||||||
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
||||||
// Trustee Views
|
// Trustee Views
|
||||||
|
|
@ -28,7 +30,6 @@ import { RealEstatePekView, RealEstateInstanceRolesPlaceholder } from './views/r
|
||||||
|
|
||||||
// GraphicalEditor Views
|
// GraphicalEditor Views
|
||||||
import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage';
|
import { GraphicalEditorPage } from './views/graphicalEditor/GraphicalEditorPage';
|
||||||
import { GraphicalEditorWorkflowsPage } from './views/graphicalEditor/GraphicalEditorWorkflowsPage';
|
|
||||||
import { GraphicalEditorWorkflowsTasksPage } from './views/graphicalEditor/GraphicalEditorWorkflowsTasksPage';
|
import { GraphicalEditorWorkflowsTasksPage } from './views/graphicalEditor/GraphicalEditorWorkflowsTasksPage';
|
||||||
import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage';
|
import { GraphicalEditorTemplatesPage } from './views/graphicalEditor/GraphicalEditorTemplatesPage';
|
||||||
// Workspace Views
|
// Workspace Views
|
||||||
|
|
@ -148,7 +149,6 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
},
|
},
|
||||||
graphicalEditor: {
|
graphicalEditor: {
|
||||||
editor: GraphicalEditorPage,
|
editor: GraphicalEditorPage,
|
||||||
workflows: GraphicalEditorWorkflowsPage,
|
|
||||||
'workflows-tasks': GraphicalEditorWorkflowsTasksPage,
|
'workflows-tasks': GraphicalEditorWorkflowsTasksPage,
|
||||||
templates: GraphicalEditorTemplatesPage,
|
templates: GraphicalEditorTemplatesPage,
|
||||||
},
|
},
|
||||||
|
|
@ -192,6 +192,7 @@ interface FeatureViewPageProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
|
const location = useLocation();
|
||||||
const { instance, featureCode, isValid } = useCurrentInstance();
|
const { instance, featureCode, isValid } = useCurrentInstance();
|
||||||
|
|
||||||
// Berechtigungs-Check
|
// Berechtigungs-Check
|
||||||
|
|
@ -227,19 +228,9 @@ export const FeatureViewPage: React.FC<FeatureViewPageProps> = ({ view }) => {
|
||||||
return <AccessDenied />;
|
return <AccessDenied />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Workspace dashboard is rendered persistently by WorkspaceKeepAlive at MainLayout level;
|
// Feature outlet is hidden for paths configured in KEEP_ALIVE_ROUTES (rendered in MainLayout).
|
||||||
// other workspace views (e.g. settings, editor) use the standard FeatureViewPage rendering.
|
// Add new persistent workspace URLs there if needed.
|
||||||
if (featureCode === 'workspace' && view !== 'settings' && view !== 'editor' && view !== 'rag-insights') {
|
if (hideFeatureOutlet(location.pathname)) {
|
||||||
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') {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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).
|
* (manual first, then form or api).
|
||||||
*/
|
*/
|
||||||
function getPrimaryEntryPoint(wf: Automation2Workflow) {
|
function getPrimaryEntryPoint(wf: Automation2Workflow) {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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 {};
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
34
src/types/keepAlive.types.ts
Normal file
34
src/types/keepAlive.types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -259,7 +259,6 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
||||||
icon: 'sitemap',
|
icon: 'sitemap',
|
||||||
views: [
|
views: [
|
||||||
{ code: 'editor', label: 'Editor', path: 'editor' },
|
{ code: 'editor', label: 'Editor', path: 'editor' },
|
||||||
{ code: 'workflows', label: 'Workflows', path: 'workflows' },
|
|
||||||
{ code: 'templates', label: 'Vorlagen', path: 'templates' },
|
{ code: 'templates', label: 'Vorlagen', path: 'templates' },
|
||||||
{ code: 'workflows-tasks', label: 'Tasks', path: 'workflows-tasks' },
|
{ code: 'workflows-tasks', label: 'Tasks', path: 'workflows-tasks' },
|
||||||
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
|
{ code: 'dashboard', label: 'Dashboard', path: 'dashboard' },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue