enhanced generic navigation tree

This commit is contained in:
ValueOn AG 2026-02-10 00:10:10 +01:00
parent 2a1cd2ef64
commit 4231727544
11 changed files with 193 additions and 161 deletions

View file

@ -8,26 +8,24 @@
* Backend liefert Blocks-Struktur mit Static und Dynamic Blocks. * Backend liefert Blocks-Struktur mit Static und Dynamic Blocks.
* UI mappt uiComponent zu Icons via pageRegistry. * UI mappt uiComponent zu Icons via pageRegistry.
* *
* FLAT STRUCTURE (kompakte Darstellung): * TREE STRUCTURE (alles collapsible):
* - SYSTEM (static block) * Meine Sicht
* - Mandant 1 * - Übersicht, Einstellungen, Prompts, Dateien, Verbindungen, Billing
*
* Mandant 1
* - 🎯 Instanz 1 (Feature-Icon + Instanz-Name) * - 🎯 Instanz 1 (Feature-Icon + Instanz-Name)
* - 💼 Instanz 2 (Feature-Icon + Instanz-Name) * - 💼 Instanz 2 (Feature-Icon + Instanz-Name)
* - BASISDATEN (static block) *
* - ADMINISTRATION (static block) * Administration
* * - Users, Mandates, Roles, ...
* Jede Instanz zeigt das Icon des zugehörigen Features.
* Keine Gruppierung nach Features - direkt Instanzen unter Mandant.
*/ */
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useNavigation } from '../../hooks/useNavigation'; import { useNavigation } from '../../hooks/useNavigation';
import type { import type {
StaticBlock,
DynamicBlock, DynamicBlock,
NavigationItem, NavigationItem,
NavigationMandate, NavigationMandate,
MandateFeature,
FeatureInstance, FeatureInstance,
FeatureView FeatureView
} from '../../hooks/useNavigation'; } from '../../hooks/useNavigation';
@ -53,13 +51,20 @@ function navigationItemToTreeNode(item: NavigationItem): TreeNodeItem {
} }
/** /**
* Convert a StaticBlock to TreeItem (section) * Convert a list of NavigationItems into a collapsible TreeNodeItem container.
* Used for grouping static items under "Meine Sicht" and "Administration".
*/ */
function staticBlockToTreeItem(block: StaticBlock): TreeItem { function _staticItemsToTreeNode(
id: string,
label: string,
items: NavigationItem[],
defaultExpanded: boolean = true,
): TreeNodeItem {
return { return {
type: 'section', id,
title: block.title, label,
children: block.items.map(navigationItemToTreeNode), children: items.map(navigationItemToTreeNode),
defaultExpanded,
}; };
} }
@ -167,40 +172,49 @@ export const MandateNavigation: React.FC = () => {
const { blocks, loading } = useNavigation('de'); const { blocks, loading } = useNavigation('de');
// Build navigation items from blocks // Build navigation items from blocks
// Groups static items into collapsible containers:
// - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.)
// - "Administration": all admin static items
// - Dynamic block (mandates) renders between them
const navigationItems: TreeItem[] = useMemo(() => { const navigationItems: TreeItem[] = useMemo(() => {
const items: TreeItem[] = []; const items: TreeItem[] = [];
// Process blocks in order (already sorted by backend) // Collect static items by category
const meineSichtItems: NavigationItem[] = [];
let adminItems: NavigationItem[] = [];
for (const block of blocks) { for (const block of blocks) {
if (block.type === 'static') { if (block.type === 'static') {
// Static block: system, workflows, basedata, migrate, admin if (block.id === 'admin') {
if (block.items.length > 0) { adminItems = [...block.items];
// Add separator before admin block } else if (block.items.length > 0) {
if (block.id === 'admin') { meineSichtItems.push(...block.items);
items.push({ type: 'separator' });
}
items.push(staticBlockToTreeItem(block));
} }
} else if (block.type === 'dynamic') {
// Dynamic block: features/mandates
// Add separator before dynamic block
items.push({ type: 'separator' });
const mandateNodes = dynamicBlockToTreeNodes(block);
if (mandateNodes.length > 0) {
items.push(...mandateNodes);
}
// Add separator after dynamic block (before next static blocks)
items.push({ type: 'separator' });
} }
} }
// Remove trailing separator if present // "Meine Sicht" - collapsible container for user-facing pages
while (items.length > 0 && (items[items.length - 1] as TreeItem & { type?: string }).type === 'separator') { if (meineSichtItems.length > 0) {
items.pop(); items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true));
} }
// Dynamic block: mandates with feature instances
for (const block of blocks) {
if (block.type === 'dynamic') {
const mandateNodes = dynamicBlockToTreeNodes(block);
if (mandateNodes.length > 0) {
if (items.length > 0) items.push({ type: 'separator' });
items.push(...mandateNodes);
}
}
}
// "Administration" - collapsible container for admin pages
if (adminItems.length > 0) {
if (items.length > 0) items.push({ type: 'separator' });
items.push(_staticItemsToTreeNode('administration', 'Administration', adminItems, false));
}
return items; return items;
}, [blocks]); }, [blocks]);

View file

@ -1,16 +1,17 @@
/** /**
* TreeNavigation Styles * TreeNavigation Styles
* *
* Flexible hierarchical navigation with support for: * Modern minimal tree navigation (Notion/Linear style):
* - Dynamic sublevels * - CSS-only disclosure triangle with smooth rotation
* - Sections and separators * - No guide lines clean indentation only
* - Various visual states (active, disabled, hover) * - Depth-aware sizing via data-depth attribute
* - Hover-reveal toggle for subtle UX
*/ */
.treeNavigation { .treeNavigation {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 1px;
padding: 0 0.5rem; padding: 0 0.5rem;
} }
@ -20,8 +21,8 @@
.separator { .separator {
height: 1px; height: 1px;
background: var(--border-color, #e0e0e0); background: var(--border-color, #e2e8f0);
margin: 0.75rem 0.5rem; margin: 0.5rem 0.75rem;
} }
/* ============================================ */ /* ============================================ */
@ -29,7 +30,7 @@
/* ============================================ */ /* ============================================ */
.treeSection { .treeSection {
margin-bottom: 0.5rem; margin-bottom: 0.25rem;
} }
.sectionHeader { .sectionHeader {
@ -40,14 +41,14 @@
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.1em; letter-spacing: 0.1em;
color: var(--text-tertiary, #888); color: var(--text-tertiary, #94a3b8);
text-transform: uppercase; text-transform: uppercase;
} }
.sectionContent { .sectionContent {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 1px;
} }
/* ============================================ */ /* ============================================ */
@ -62,9 +63,9 @@
.treeNode { .treeNode {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.375rem;
width: 100%; width: 100%;
padding: 0.5rem 0.75rem; padding: 0.375rem 0.5rem;
border: none; border: none;
border-radius: 6px; border-radius: 6px;
background: transparent; background: transparent;
@ -72,9 +73,11 @@
text-decoration: none; text-decoration: none;
font-family: inherit; font-family: inherit;
text-align: left; text-align: left;
color: var(--text-secondary, #666); color: var(--text-secondary, #64748b);
font-size: 0.875rem; font-size: 0.8125rem;
transition: all 0.15s ease; font-weight: 500;
line-height: 1.4;
transition: background 0.15s ease, color 0.15s ease;
} }
.treeNode:hover { .treeNode:hover {
@ -82,12 +85,18 @@
color: var(--text-primary, #1a1a1a); color: var(--text-primary, #1a1a1a);
} }
/* Leaf node active — strong pill highlight */
.treeNode.active { .treeNode.active {
background: var(--primary-light, #e0e7ff); background: var(--primary-light, #e0e7ff);
color: var(--primary-color, #2563eb); color: var(--primary-color, #2563eb);
font-weight: 500; font-weight: 500;
} }
/* Group/parent active — subtle text color only, no background */
.treeNode.activeGroup {
color: var(--primary-color, #2563eb);
}
.treeNode.disabled { .treeNode.disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@ -95,105 +104,109 @@
} }
/* ============================================ */ /* ============================================ */
/* LEVEL-SPECIFIC STYLES */ /* DEPTH-SPECIFIC STYLES (via data-depth) */
/* ============================================ */ /* ============================================ */
/* Root level (level 0) */ .treeNode[data-depth="0"] {
.levelRoot {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
color: var(--text-primary, #1a1a1a); color: var(--text-primary, #1a1a1a);
padding: 0.625rem 0.75rem; padding: 0.5rem 0.5rem;
} }
.levelRoot .nodeLabel { .treeNode[data-depth="1"] {
flex: 1;
}
/* Level 1 */
.levelOne {
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 500; font-weight: 500;
color: var(--text-secondary, #666);
padding: 0.5rem 0.75rem;
} }
/* Level 2 */ .treeNode[data-depth="2"],
.levelTwo { .treeNode[data-depth="3"],
.treeNode[data-depth="4"],
.treeNode[data-depth="5"] {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 500; font-weight: 400;
color: var(--text-secondary, #666);
padding: 0.375rem 0.5rem;
}
/* Level 3 */
.levelThree {
font-size: 0.75rem;
color: var(--text-secondary, #666);
padding: 0.375rem 0.5rem;
}
/* Deep levels (4+) */
.levelDeep {
font-size: 0.6875rem;
color: var(--text-tertiary, #888);
padding: 0.25rem 0.5rem;
} }
/* ============================================ */ /* ============================================ */
/* NODE CHILDREN (INDENTATION) */ /* NODE CHILDREN (INDENTATION ONLY) */
/* ============================================ */ /* ============================================ */
.treeNodeChildren { .treeNodeChildren {
margin-left: 0.25rem; margin-left: 0.75rem;
padding-left: 0.75rem; padding-left: 0.5rem;
border-left: 2px solid var(--border-color, #e0e0e0);
} }
/* Active parent highlights the border */ /* ============================================ */
.treeNodeContainer:has(> .treeNode.active) > .treeNodeChildren { /* TOGGLE (CSS-only disclosure triangle) */
/* ============================================ */
.toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.125rem;
height: 1.125rem;
flex-shrink: 0;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s ease;
}
/* The triangle — pure CSS, no icon needed */
.toggle::after {
content: '';
display: block;
width: 0;
height: 0;
border-left: 4.5px solid var(--text-tertiary, #94a3b8);
border-top: 3.5px solid transparent;
border-bottom: 3.5px solid transparent;
transition: transform 0.2s ease, border-color 0.15s ease;
}
/* Rotate triangle when expanded */
.toggleExpanded::after {
transform: rotate(90deg);
}
/* Hover feedback */
.toggle:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
}
.toggle:hover::after {
border-left-color: var(--text-primary, #1a1a1a);
}
/* Active node toggle */
.treeNode.active .toggle::after,
.treeNode.activeGroup .toggle::after {
border-left-color: var(--primary-color, #2563eb); border-left-color: var(--primary-color, #2563eb);
} }
/* Also highlight if any descendant is active */ /* Spacer for leaf nodes (keeps alignment with toggle nodes) */
.treeNodeContainer:has(.treeNode.active) > .treeNodeChildren { .toggleSpacer {
border-left-color: var(--primary-light, #93c5fd); width: 1.125rem;
flex-shrink: 0;
} }
/* ============================================ */ /* ============================================ */
/* NODE ELEMENTS */ /* NODE ELEMENTS */
/* ============================================ */ /* ============================================ */
.chevron {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
font-size: 0.625rem;
color: var(--text-tertiary, #888);
flex-shrink: 0;
cursor: pointer;
border-radius: 3px;
transition: background 0.1s ease;
}
.chevron:hover {
background: var(--hover-bg, rgba(0, 0, 0, 0.06));
}
.chevronSpacer {
width: 1rem;
flex-shrink: 0;
}
.nodeIcon { .nodeIcon {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 1rem; font-size: 0.875rem;
flex-shrink: 0; flex-shrink: 0;
color: inherit; color: inherit;
opacity: 0.8;
}
.treeNode.active .nodeIcon,
.treeNode.activeGroup .nodeIcon {
opacity: 1;
} }
.nodeLabel { .nodeLabel {
@ -208,7 +221,7 @@
padding: 0.0625rem 0.375rem; padding: 0.0625rem 0.375rem;
background: var(--surface-color, #f0f0f0); background: var(--surface-color, #f0f0f0);
border-radius: 9999px; border-radius: 9999px;
color: var(--text-tertiary, #888); color: var(--text-tertiary, #94a3b8);
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.025em; letter-spacing: 0.025em;
flex-shrink: 0; flex-shrink: 0;
@ -262,41 +275,45 @@
color: var(--primary-light, #93c5fd); color: var(--primary-light, #93c5fd);
} }
:global(.dark-theme) .levelRoot { :global(.dark-theme) .treeNode.activeGroup {
color: var(--primary-light, #93c5fd);
}
:global(.dark-theme) .treeNode[data-depth="0"] {
color: var(--text-primary-dark, #fff); color: var(--text-primary-dark, #fff);
} }
:global(.dark-theme) .levelOne, :global(.dark-theme) .toggle::after {
:global(.dark-theme) .levelTwo, border-left-color: var(--text-tertiary-dark, #555);
:global(.dark-theme) .levelThree {
color: var(--text-secondary-dark, #aaa);
} }
:global(.dark-theme) .levelDeep { :global(.dark-theme) .toggle:hover {
color: var(--text-tertiary-dark, #888); background: var(--hover-bg-dark, rgba(255, 255, 255, 0.08));
} }
:global(.dark-theme) .treeNodeChildren { :global(.dark-theme) .toggle:hover::after {
border-left-color: var(--border-dark, #444); border-left-color: var(--text-primary-dark, #ddd);
} }
:global(.dark-theme) .treeNodeContainer:has(.treeNode.active) > .treeNodeChildren { :global(.dark-theme) .treeNode.active .toggle::after,
:global(.dark-theme) .treeNode.activeGroup .toggle::after {
border-left-color: var(--primary-light, #93c5fd); border-left-color: var(--primary-light, #93c5fd);
} }
:global(.dark-theme) .nodeIcon {
opacity: 0.7;
}
:global(.dark-theme) .treeNode.active .nodeIcon,
:global(.dark-theme) .treeNode.activeGroup .nodeIcon {
opacity: 1;
}
:global(.dark-theme) .nodeBadge { :global(.dark-theme) .nodeBadge {
background: var(--surface-dark, #2a2a2a); background: var(--surface-dark, #2a2a2a);
color: var(--text-tertiary-dark, #888); color: var(--text-tertiary-dark, #888);
} }
:global(.dark-theme) .chevron {
color: var(--text-tertiary-dark, #666);
}
:global(.dark-theme) .chevron:hover {
background: var(--hover-bg-dark, rgba(255, 255, 255, 0.1));
}
:global(.dark-theme) .treeNode.active .nodeBadge { :global(.dark-theme) .treeNode.active .nodeBadge {
background: var(--primary-color, #2563eb); background: var(--primary-color, #2563eb);
color: white; color: white;

View file

@ -12,7 +12,6 @@
import React, { useState, useEffect, ReactNode } from 'react'; import React, { useState, useEffect, ReactNode } from 'react';
import { NavLink, useLocation } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import { FaChevronDown, FaChevronRight } from 'react-icons/fa';
import styles from './TreeNavigation.module.css'; import styles from './TreeNavigation.module.css';
// ============================================================================= // =============================================================================
@ -155,8 +154,11 @@ const TreeNode: React.FC<TreeNodeProps> = ({
} }
}, [currentPath, autoExpandActive, node]); }, [currentPath, autoExpandActive, node]);
// Check if this exact node is active // Check if this node is active (exact match or ancestor of active path)
const isActive = node.path ? currentPath === node.path || currentPath.startsWith(node.path + '/') : false; const isActive = node.path ? currentPath === node.path || currentPath.startsWith(node.path + '/') : false;
// Differentiate: leaf active (strong highlight) vs group active (subtle text only)
const isLeafActive = isActive && !hasChildren;
const isGroupActive = isActive && !!hasChildren;
// Handle click // Handle click
const handleClick = (e: React.MouseEvent) => { const handleClick = (e: React.MouseEvent) => {
@ -186,34 +188,24 @@ const TreeNode: React.FC<TreeNodeProps> = ({
} }
}; };
// Handle chevron click separately // Handle toggle click separately (expand/collapse)
const handleChevronClick = (e: React.MouseEvent) => { const handleToggleClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setIsExpanded(!isExpanded); setIsExpanded(!isExpanded);
}; };
// Get level-specific styles
const getLevelClass = () => {
switch (level) {
case 0: return styles.levelRoot;
case 1: return styles.levelOne;
case 2: return styles.levelTwo;
case 3: return styles.levelThree;
default: return styles.levelDeep;
}
};
// Render the node content // Render the node content
const nodeContent = ( const nodeContent = (
<> <>
{isExpandable && ( {isExpandable && (
<span className={styles.chevron} onClick={handleChevronClick}> <span
{isExpanded ? <FaChevronDown /> : <FaChevronRight />} className={`${styles.toggle} ${isExpanded ? styles.toggleExpanded : ''}`}
</span> onClick={handleToggleClick}
/>
)} )}
{!isExpandable && hasChildren === false && ( {!isExpandable && hasChildren === false && (
<span className={styles.chevronSpacer} /> <span className={styles.toggleSpacer} />
)} )}
{node.icon && <span className={styles.nodeIcon}>{node.icon}</span>} {node.icon && <span className={styles.nodeIcon}>{node.icon}</span>}
<span className={styles.nodeLabel} title={node.label}>{node.label}</span> <span className={styles.nodeLabel} title={node.label}>{node.label}</span>
@ -228,7 +220,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
); );
// Determine if we should render as NavLink or button // Determine if we should render as NavLink or button
const nodeClasses = `${styles.treeNode} ${getLevelClass()} ${isActive ? styles.active : ''} ${node.disabled ? styles.disabled : ''} ${node.className || ''}`; const nodeClasses = `${styles.treeNode} ${isLeafActive ? styles.active : ''} ${isGroupActive ? styles.activeGroup : ''} ${node.disabled ? styles.disabled : ''} ${node.className || ''}`;
const nodeElement = node.path ? ( const nodeElement = node.path ? (
<NavLink <NavLink
@ -236,6 +228,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
className={nodeClasses} className={nodeClasses}
onClick={handleClick} onClick={handleClick}
data-id={node.dataId} data-id={node.dataId}
data-depth={level}
> >
{nodeContent} {nodeContent}
</NavLink> </NavLink>
@ -246,6 +239,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
onClick={handleClick} onClick={handleClick}
disabled={node.disabled} disabled={node.disabled}
data-id={node.dataId} data-id={node.dataId}
data-depth={level}
> >
{nodeContent} {nodeContent}
</button> </button>

View file

@ -60,6 +60,7 @@ export interface Role {
export interface Mandate { export interface Mandate {
id: string; id: string;
name: string | { [key: string]: string }; name: string | { [key: string]: string };
label?: string;
code?: string; code?: string;
language?: string; language?: string;
isSystem?: boolean; isSystem?: boolean;

View file

@ -24,6 +24,7 @@ import { FeatureInstanceWizard } from './FeatureInstanceWizard';
import { InstanceHierarchyView } from './InstanceHierarchyView'; import { InstanceHierarchyView } from './InstanceHierarchyView';
function getMandateName(mandate: Mandate): string { function getMandateName(mandate: Mandate): string {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') { if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id; return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
} }

View file

@ -310,6 +310,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
// Get mandate name // Get mandate name
const getMandateName = (mandate: Mandate) => { const getMandateName = (mandate: Mandate) => {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') { if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id; return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
} }

View file

@ -86,7 +86,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
allOptions.push({ allOptions.push({
mandateId: mandate.id, mandateId: mandate.id,
instanceId: inst.id, instanceId: inst.id,
mandateName: typeof mandate.name === 'string' ? mandate.name : (mandate.name?.de || mandate.name?.en || Object.values(mandate.name || {})[0] || mandate.id), mandateName: mandate.label || (typeof mandate.name === 'string' ? mandate.name : (mandate.name?.de || mandate.name?.en || Object.values(mandate.name || {})[0] || mandate.id)),
instanceLabel: inst.label || inst.id, instanceLabel: inst.label || inst.id,
featureCode: inst.featureCode, featureCode: inst.featureCode,
combinedKey: `${mandate.id}:${inst.id}`, combinedKey: `${mandate.id}:${inst.id}`,

View file

@ -226,6 +226,7 @@ export const AdminInvitationsPage: React.FC = () => {
// Get mandate name // Get mandate name
const getMandateName = (mandate: Mandate) => { const getMandateName = (mandate: Mandate) => {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') { if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id; return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
} }

View file

@ -285,6 +285,7 @@ export const AdminMandateRolesPage: React.FC = () => {
// Get mandate name // Get mandate name
const getMandateName = (mandate: Mandate) => { const getMandateName = (mandate: Mandate) => {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') { if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id; return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
} }

View file

@ -248,6 +248,7 @@ export const AdminUserMandatesPage: React.FC = () => {
// Get mandate name // Get mandate name
const getMandateName = (mandate: Mandate) => { const getMandateName = (mandate: Mandate) => {
if (mandate.label) return mandate.label;
if (typeof mandate.name === 'object') { if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id; return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
} }

View file

@ -116,7 +116,8 @@ export interface MandateFeature {
*/ */
export interface Mandate { export interface Mandate {
id: string; // mandateId id: string; // mandateId
name: string; // Anzeige-Name name: string; // Technischer Identifier
label?: string; // Anzeige-Label (fuer FK-Referenzen und UI)
code?: string; // Optionaler Code code?: string; // Optionaler Code
features: MandateFeature[]; features: MandateFeature[];
} }