removed legacy code

This commit is contained in:
Ida Dittrich 2026-01-29 10:06:10 +01:00
parent 04590b78c9
commit 386b710c53
57 changed files with 12 additions and 16487 deletions

View file

@ -1,129 +0,0 @@
import React from 'react'
import styles from './SidebarStyles/Sidebar.module.css'
import SidebarItem from './SidebarItem';
import useSidebarFromPageConfigs from '../../hooks/useSidebar';
import { useSidebar as useGenericSidebar } from '../../core/PageManager/SidebarProvider';
import SidebarUser from './SidebarUser';
import { useSidebarLogic } from './sidebarLogic';
import { SidebarProps } from './sidebarTypes';
import { GoSidebarExpand, GoSidebarCollapse } from 'react-icons/go';
const Sidebar: React.FC<SidebarProps> = ({ data }) => {
const sidebar = useSidebarLogic();
// Ensure data is always an array
const sidebarItems = Array.isArray(data) ? data : [];
return (
<div
className={`${styles.sidebarContainer} ${sidebar.state.isMinimized ? styles.minimized : ''}`}
>
<div className={styles.logoContainer}>
<div className={styles.logoWrapper}>
<div className={styles.logoText}>
<span className={styles.logoPower}>Power</span>
<span className={styles.logoOn}>On</span>
</div>
</div>
{/* Minimize/Expand Toggle Button */}
<button
className={styles.toggleButton}
onClick={sidebar.state.isMinimized ? sidebar.expandSidebar : sidebar.minimizeSidebar}
title={sidebar.state.isMinimized ? "Expand Sidebar" : "Minimize Sidebar"}
>
{sidebar.state.isMinimized ? (
<GoSidebarCollapse size={20} />
) : (
<GoSidebarExpand size={20} />
)}
</button>
</div>
<div
className={styles.sidebar}
>
{sidebarItems.map(item => {
return (
<SidebarItem
key={item.id}
item={item}
isOpen={sidebar.isItemOpen(item.id)}
onToggle={() => sidebar.toggleItem(item.id)}
isActive={sidebar.isItemActive(item.link)}
isMinimized={sidebar.state.isMinimized}
/>
);
})}
</div>
<SidebarUser
isMinimized={sidebar.state.isMinimized}
/>
</div>
)
}
const SidebarWithData: React.FC = () => {
// Try to use the generic sidebar first, fallback to old system
let sidebarData, isLoading, error;
try {
const genericSidebar = useGenericSidebar();
sidebarData = genericSidebar.sidebarItems;
isLoading = genericSidebar.loading;
error = genericSidebar.error;
} catch {
// Fallback to old system if generic sidebar is not available
const oldSidebar = useSidebarFromPageConfigs();
sidebarData = oldSidebar.items;
isLoading = oldSidebar.isLoading;
error = null;
}
if (isLoading) {
return (
<div className={`${styles.sidebarContainer}`}>
<div className={styles.logoContainer}>
<div className={styles.logoWrapper}>
<div className={styles.logoText}>
<span className={styles.logoPower}>Power</span>
<span className={styles.logoOn}>On</span>
</div>
</div>
</div>
<div className={styles.sidebar}>
<div style={{ padding: '20px', textAlign: 'center' }}>
Loading...
</div>
</div>
</div>
);
}
if (error) {
return (
<div className={`${styles.sidebarContainer}`}>
<div className={styles.logoContainer}>
<div className={styles.logoWrapper}>
<div className={styles.logoText}>
<span className={styles.logoPower}>Power</span>
<span className={styles.logoOn}>On</span>
</div>
</div>
</div>
<div className={styles.sidebar}>
<div style={{ padding: '20px', textAlign: 'center', color: 'red' }}>
Error: {error}
</div>
</div>
</div>
);
}
return <Sidebar data={sidebarData} />;
};
export default SidebarWithData;

View file

@ -1,650 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import { IoIosArrowDown } from "react-icons/io";
import { motion, AnimatePresence } from "framer-motion";
import styles from './SidebarStyles/SidebarItem.module.css';
import { SidebarItemProps } from "./sidebarTypes";
const SidebarItem: React.FC<SidebarItemProps> = React.memo(({
item,
isOpen,
onToggle,
isActive,
isMinimized
}) => {
const location = useLocation();
const Icon = item.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
const hasSubItems = item.submenu && item.submenu.length > 0;
const isDisabled = item.moduleEnabled === false;
const iconContainerRef = useRef<HTMLDivElement>(null);
const depth = item.depth || 0; // Get depth from item, default to 0
// CSS already has 27px padding on .menu li, so we only add extra for nested items
// Depth 0: 0px extra (uses CSS default 27px), Depth 1: 20px extra, Depth 2: 40px extra
const indentPx = depth === 0 ? 0 : (depth * 20);
// Local state for nested submenus (not tracked by sidebar logic)
const [nestedOpenStates, setNestedOpenStates] = useState<Record<string, boolean>>({});
// Check if a submenu item is active
const isSubmenuItemActive = (itemPath?: string) => {
if (!itemPath) return false;
const currentPath = location.pathname;
// Exact match or prefix match at path segment boundary
if (currentPath === itemPath) return true;
if (currentPath.startsWith(itemPath)) {
const nextChar = currentPath[itemPath.length];
if (nextChar === '/' || nextChar === undefined) return true;
}
return false;
};
// Toggle nested submenu
const toggleNestedSubmenu = (itemId: string) => {
setNestedOpenStates(prev => ({
...prev,
[itemId]: !prev[itemId]
}));
};
// Fix SVG dimensions when minimized - react-icons uses 1em which can be invisible
useEffect(() => {
if (isMinimized && iconContainerRef.current) {
const wrapper = iconContainerRef.current;
const svg = wrapper.querySelector('svg');
if (svg) {
// Force explicit pixel dimensions
svg.setAttribute('width', '25');
svg.setAttribute('height', '25');
svg.style.cssText = 'width: 25px !important; height: 25px !important; display: block !important;';
// Get the actual color from parent li element
const parentLi = wrapper.closest('li');
// Force color directly - use black for now to ensure visibility
const iconColor = '#000000'; // Force black for visibility
svg.style.setProperty('color', iconColor, 'important');
svg.style.setProperty('fill', iconColor, 'important');
svg.style.setProperty('stroke', iconColor, 'important');
svg.setAttribute('fill', iconColor);
svg.setAttribute('stroke', iconColor);
// Set fill/stroke on all paths - use black
const paths = svg.querySelectorAll('path');
paths.forEach(path => {
const originalFill = path.getAttribute('fill');
const originalStroke = path.getAttribute('stroke');
const strokeWidth = path.getAttribute('stroke-width');
if (originalFill === 'none' || (!originalFill && originalStroke)) {
path.removeAttribute('fill');
path.setAttribute('stroke', iconColor);
if (!strokeWidth || strokeWidth === '0') {
path.setAttribute('stroke-width', '2');
}
path.style.setProperty('stroke', iconColor, 'important');
path.style.setProperty('stroke-width', '2px', 'important');
path.style.setProperty('fill', 'none', 'important');
} else {
path.removeAttribute('fill');
path.setAttribute('fill', iconColor);
path.style.setProperty('fill', iconColor, 'important');
path.style.setProperty('stroke', iconColor, 'important');
}
});
// Debug: Check if wrapper is visible
const wrapperRect = wrapper.getBoundingClientRect();
const wrapperComputed = window.getComputedStyle(wrapper);
const button = wrapper.parentElement?.querySelector('button');
const buttonComputed = button ? window.getComputedStyle(button) : null;
const svgRect = svg.getBoundingClientRect();
const svgComputed = window.getComputedStyle(svg);
const firstPath = paths[0];
const pathComputed = firstPath ? window.getComputedStyle(firstPath) : null;
const parentLiComputed = parentLi ? window.getComputedStyle(parentLi) : null;
const parentMenu = wrapper.closest(`.${styles.menu}`);
const parentMenuComputed = parentMenu ? window.getComputedStyle(parentMenu) : null;
// Check what's actually at the icon position
const centerX = wrapperRect.left + wrapperRect.width / 2;
const centerY = wrapperRect.top + wrapperRect.height / 2;
const elementsAtPoint = document.elementsFromPoint(centerX, centerY);
const wrapperInElements = elementsAtPoint.includes(wrapper);
console.log(`[${item.name}] Icon wrapper check:`, {
hasSubItems,
wrapperVisible: wrapperComputed.visibility === 'visible',
wrapperOpacity: wrapperComputed.opacity,
wrapperZIndex: wrapperComputed.zIndex,
wrapperDisplay: wrapperComputed.display,
wrapperBackgroundColor: wrapperComputed.backgroundColor,
wrapperRect: wrapperRect ? `width: ${wrapperRect.width}, height: ${wrapperRect.height}, top: ${wrapperRect.top}, left: ${wrapperRect.left}` : 'no rect',
svgExists: !!svg,
svgRect: svgRect ? `width: ${svgRect.width}, height: ${svgRect.height}, top: ${svgRect.top}, left: ${svgRect.left}` : 'no rect',
svgDisplay: svgComputed.display,
svgVisibility: svgComputed.visibility,
svgOpacity: svgComputed.opacity,
svgColor: svgComputed.color,
pathsCount: paths.length,
firstPathFill: pathComputed?.fill,
firstPathOpacity: pathComputed?.opacity,
buttonExists: !!button,
buttonZIndex: buttonComputed?.zIndex,
buttonPosition: buttonComputed?.position,
buttonRect: button ? button.getBoundingClientRect() : null,
elementsAtIconCenter: elementsAtPoint.slice(0, 5).map(el => {
let className: string = '';
if (typeof el.className === 'string') {
className = el.className;
} else if (el.className && typeof el.className === 'object' && 'baseVal' in el.className) {
className = (el.className as { baseVal?: string }).baseVal || '';
} else if (el.className) {
className = String(el.className);
}
return {
tag: el.tagName,
class: className?.split(' ')[0] || 'no-class',
zIndex: window.getComputedStyle(el).zIndex
};
}),
wrapperInElements: wrapperInElements,
parentLiOverflow: parentLiComputed?.overflow,
parentLiClipPath: parentLiComputed?.clipPath,
parentMenuOverflow: parentMenuComputed?.overflow,
parentMenuClipPath: parentMenuComputed?.clipPath
});
} else {
console.error(`[${item.name}] SVG NOT FOUND in wrapper!`);
}
}
}, [isMinimized, isActive, item.name, hasSubItems]);
const toggleSubmenu = (e: React.MouseEvent) => {
if (isDisabled) {
e.preventDefault();
return;
}
e.preventDefault();
e.stopPropagation();
onToggle();
};
const handleLinkClick = (e: React.MouseEvent) => {
if (isDisabled) {
e.preventDefault();
return;
}
// If item has submenu or no link (navigation node), prevent navigation and only toggle submenu
if (hasSubItems || !item.link) {
e.preventDefault();
e.stopPropagation();
onToggle();
return;
}
// Allow normal navigation for items without submenu and with a link
};
// Render recursive submenu items
const renderSubmenuItems = () => {
if (!hasSubItems || !item.submenu) return null;
if (isMinimized) {
// Horizontal layout for minimized sidebar - recursive for all levels
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
duration: 0.4,
ease: [0.25, 0.1, 0.25, 1],
delay: depth > 0 ? 0.05 : 0.1
}
}}
exit={{
opacity: 0,
transition: {
duration: 0.3,
ease: [0.25, 0.1, 0.25, 1]
}
}}
className={styles.submenuHorizontalContainer}
>
<ul className={styles.submenuHorizontalList}>
{item.submenu.map((subitem, index) => {
const SubIcon = subitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
const subIsActive = isSubmenuItemActive(subitem.link);
const hasNestedSubmenu = subitem.submenu && subitem.submenu.length > 0;
const subIsOpen = nestedOpenStates[subitem.id] || false;
// In minimized mode, items with nested submenus should render as icon buttons
// Their submenu will expand below them when clicked
if (hasNestedSubmenu) {
return (
<motion.li
key={subitem.id}
className={`${styles.submenuHorizontalItem} ${subIsActive ? styles.active : ''}`}
style={{ position: 'relative' }}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
duration: 0.35,
ease: [0.25, 0.1, 0.25, 1],
delay: (depth > 0 ? 0.05 : 0.1) + (index * 0.02)
}
}}
exit={{
opacity: 0,
transition: {
duration: 0.25,
ease: [0.25, 0.1, 0.25, 1]
}
}}
>
<button
onClick={() => toggleNestedSubmenu(subitem.id)}
className={`${styles.submenuHorizontalLink} ${subIsActive ? styles.activeLink : ''}`}
title={subitem.name}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '40px',
height: '40px',
borderRadius: '12px',
background: subIsActive ? 'var(--color-secondary)' : 'none',
border: 'none',
cursor: 'pointer',
padding: 0,
margin: 0
}}
>
{SubIcon && (
<SubIcon
className={styles.submenuHorizontalIcon}
style={{
width: '16px',
height: '16px',
color: subIsActive ? 'white' : '#181818',
display: 'block'
}}
/>
)}
</button>
{/* Render nested submenu horizontally when expanded */}
{subIsOpen && (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
duration: 0.35,
ease: [0.25, 0.1, 0.25, 1]
}
}}
exit={{
opacity: 0,
transition: {
duration: 0.25,
ease: [0.25, 0.1, 0.25, 1]
}
}}
className={styles.submenuHorizontalContainer}
style={{
position: 'absolute',
top: '100%',
left: 0,
marginTop: '4px',
zIndex: 100,
background: 'var(--color-bg)',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
padding: '4px'
}}
>
<ul className={styles.submenuHorizontalList}>
{subitem.submenu?.map(nestedSubitem => {
const NestedIcon = nestedSubitem.icon as React.ComponentType<React.SVGProps<SVGSVGElement>>;
const nestedIsActive = isSubmenuItemActive(nestedSubitem.link);
return (
<motion.li
key={nestedSubitem.id}
className={`${styles.submenuHorizontalItem} ${nestedIsActive ? styles.active : ''}`}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
duration: 0.25,
ease: [0.25, 0.1, 0.25, 1],
delay: 0.05
}
}}
exit={{
opacity: 0,
transition: {
duration: 0.2,
ease: [0.25, 0.1, 0.25, 1]
}
}}
>
<Link
to={nestedSubitem.link || '#'}
title={nestedSubitem.name}
className={`${styles.submenuHorizontalLink} ${nestedIsActive ? styles.activeLink : ''}`}
>
{NestedIcon && (
<NestedIcon
className={styles.submenuHorizontalIcon}
style={{
width: '16px',
height: '16px',
color: nestedIsActive ? 'white' : '#181818',
display: 'block'
}}
/>
)}
</Link>
</motion.li>
);
})}
</ul>
</motion.div>
</AnimatePresence>
)}
</motion.li>
);
}
// Regular item without nested submenu - render as icon link with smooth animation
return (
<motion.li
key={subitem.id}
className={`${styles.submenuHorizontalItem} ${subIsActive ? styles.active : ''}`}
initial={{ opacity: 0 }}
animate={{
opacity: 1,
transition: {
duration: 0.35,
ease: [0.25, 0.1, 0.25, 1],
delay: (depth > 0 ? 0.05 : 0.1) + (index * 0.02)
}
}}
exit={{
opacity: 0,
transition: {
duration: 0.25,
ease: [0.25, 0.1, 0.25, 1]
}
}}
>
<Link
to={subitem.link || '#'}
title={subitem.name}
className={`${styles.submenuHorizontalLink} ${subIsActive ? styles.activeLink : ''}`}
>
{SubIcon && (
<SubIcon
className={styles.submenuHorizontalIcon}
style={{
width: '16px',
height: '16px',
color: subIsActive ? 'white' : '#181818',
display: 'block'
}}
/>
)}
</Link>
</motion.li>
);
})}
</ul>
</motion.div>
)}
</AnimatePresence>
);
} else {
// Vertical layout for expanded sidebar
return (
<AnimatePresence>
{isOpen && (
<motion.div
layout
initial={{ height: 0, opacity: 0 }}
animate={{
height: "auto",
opacity: 1,
transition: {
height: { duration: 0.3, ease: "easeInOut", delay: 0 },
opacity: { duration: 0.25, ease: "easeInOut", delay: 0.3 }
}
}}
exit={{
height: 0,
opacity: 0,
transition: {
opacity: { duration: 0.25, ease: "easeInOut", delay: 0 },
height: { duration: 0.3, ease: "easeInOut", delay: 0.25 }
}
}}
style={{ overflow: "visible" }}
className={styles.submenu}
>
<motion.div
className={styles.submenuLineContainer}
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.3, duration: 0.25 } }}
exit={{ opacity: 0, transition: { duration: 0.25, delay: 0 } }}
>
{/* Render items directly as flex column children - same structure as first level */}
{/* This allows nested submenus to push siblings down just like first level */}
{item.submenu.map(subitem => {
const subIsActive = isSubmenuItemActive(subitem.link);
const subIsOpen = nestedOpenStates[subitem.id] || false;
const subDepth = subitem.depth !== undefined ? subitem.depth : (depth === 0 ? 1 : depth + 1);
const sidebarItemData = {
id: subitem.id,
name: subitem.name,
link: subitem.link,
icon: subitem.icon,
submenu: subitem.submenu,
moduleEnabled: true,
depth: subDepth
};
// Render ALL items as recursive SidebarItem - direct children of flex column
// This matches the first-level structure where SidebarItems are direct siblings
return (
<SidebarItem
key={subitem.id}
item={sidebarItemData}
isOpen={subIsOpen}
onToggle={() => toggleNestedSubmenu(subitem.id)}
isActive={subIsActive}
isMinimized={isMinimized}
/>
);
})}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
};
return (
<div className={`${styles.menu} ${isMinimized ? styles.minimized : ''} ${isDisabled ? styles.disabled : ''}`}>
<li
className={`${isActive ? styles.active : ""} ${isDisabled ? styles.disabledItem : ""}`}
data-item-name={item.name}
style={{ display: 'flex', alignItems: 'center', width: '100%' }}
>
{/* Icon and text container with indentation - takes remaining space */}
<div
style={{
display: 'flex',
alignItems: 'center',
paddingLeft: `${indentPx}px`, // Add 20px per level for nested items (depth 0 = 0px, depth 1 = 20px, depth 2 = 40px)
flex: 1,
minWidth: 0, // Allow flex shrinking
overflow: 'hidden' // Prevent overflow
}}
>
{/* Icon - always render, CSS handles positioning */}
{Icon && !isMinimized && (
<Icon
className={`${styles.icon} ${isDisabled ? styles.disabledIcon : ''}`}
/>
)}
{/* Text - hidden when minimized */}
{!isMinimized && (
<>
{hasSubItems || !item.link ? (
// For items with submenu or navigation nodes (no link)
<span
className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{item.name}
</span>
) : (
// For items without submenu and with a link
<span
className={`${styles.menuText} ${isDisabled ? styles.disabledText : ''}`}
style={{
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap'
}}
>
{item.name}
</span>
)}
</>
)}
</div>
{/* Expand button - always right-aligned, not indented */}
{!isMinimized && (hasSubItems || !item.link) && (
<button
onClick={toggleSubmenu}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 8px 0 0',
background: 'none',
border: 'none',
cursor: 'pointer',
flexShrink: 0, // Don't shrink
width: 'auto',
minWidth: 'auto',
color: 'inherit'
}}
aria-disabled={isDisabled}
title={isDisabled ? `${item.name} (Module disabled)` : `Toggle ${item.name} submenu`}
>
{hasSubItems && (
<IoIosArrowDown className={`${styles.hassubmenu} ${isOpen ? styles.rotated : ''} ${isDisabled ? styles.disabledArrow : ''}`} />
)}
</button>
)}
{/* Link wrapper for items with link and no submenu - spans full width but text is indented */}
{!isMinimized && !hasSubItems && item.link && (
<Link
to={isDisabled ? "#" : (item.link || "#")}
className={`${styles.menuTextLink} ${isDisabled ? styles.disabledLink : ''}`}
onClick={handleLinkClick}
aria-disabled={isDisabled}
title={isDisabled ? `${item.name} (Module disabled)` : item.name}
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
display: 'flex',
alignItems: 'center',
paddingLeft: `${indentPx}px`, // Match indentation of text container
pointerEvents: isDisabled ? 'none' : 'auto'
}}
/>
)}
{/* Icon for minimized state - render directly as child of li */}
{Icon && isMinimized && (
<div
ref={iconContainerRef}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '25px',
height: '25px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
pointerEvents: 'none',
zIndex: 10
}}
>
<Icon
className={`${styles.icon} ${styles.iconMinimized} ${isDisabled ? styles.disabledIcon : ''}`}
style={{
width: '25px',
height: '25px',
color: '#000000',
fill: '#000000'
}}
/>
</div>
)}
{/* Clickable overlay for items without submenu and with a link */}
{isMinimized && !isDisabled && !hasSubItems && item.link && (
<Link
to={item.link}
className={styles.minimizedOverlay}
title={item.name}
onClick={handleLinkClick}
/>
)}
{/* Clickable overlay for items with submenu or navigation nodes (no link) */}
{isMinimized && (hasSubItems || !item.link) && !isDisabled && (
<button
onClick={toggleSubmenu}
className={styles.minimizedSubmenuToggle}
title={`Toggle ${item.name} submenu`}
aria-expanded={isOpen}
/>
)}
</li>
{/* Recursive submenu rendering */}
{hasSubItems && !isDisabled && renderSubmenuItems()}
</div>
);
});
SidebarItem.displayName = 'SidebarItem';
export default SidebarItem;

View file

@ -1,182 +0,0 @@
/* Allgemeine Stile */
.sidebarContainer {
border-radius: 0px;
background: var(--color-bg);
/*background-image: url('../../../styles/assets/bg.jpg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
box-shadow: 0px 2px 6px 0px rgba(194, 194, 194, 0.10);*/
width: 250px;
padding: 0;
display: flex;
justify-content: flex-start;
align-items: stretch;
flex-direction: column;
height: 100vh;
max-height: 100vh;
overflow: hidden;
transition: width 0.3s ease-in-out;
position: relative;
z-index: 1;
box-sizing: border-box;
border-right: 1px solid var(--color-primary);
}
.sidebar {
display: flex;
flex-direction: column;
align-items: flex-start;
flex: 1;
min-height: 0;
font-family: var(--font-family);
overflow-y: auto;
overflow-x: hidden; /* Disable horizontal scrolling */
padding: 0;
margin: 0;
/* Ensure sidebar can scroll when content exceeds container */
/* flex: 1 makes it take available space, min-height: 0 allows it to shrink */
/* Content will expand naturally and trigger scrolling */
}
.sidebarContainer.minimized .sidebar {
overflow: visible !important;
}
.logoContainer {
display: flex;
height: 80px;
padding: 30px 20px 7px 27px;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
position: relative;
box-sizing: border-box;
}
.logoWrapper {
display: flex;
justify-content: left;
align-items: center;
opacity: 1;
transition: opacity 0.3s ease-in-out;
white-space: nowrap;
overflow: hidden;
position: absolute;
left: 20px;
top: 50%;
transform: translateY(-50%); /* Center vertically - prevents jumping */
width: calc(100% - 90px); /* Full width minus button space */
}
.logo {
max-width: 80%;
height: auto;
color: var(--color-primary);
}
.logoText {
font-family: var(--font-family);
font-size: 35px;
display: flex;
align-items: center;
letter-spacing: -0.5px;
font-weight: 200;
}
.logoPower {
color: var(--color-text);
}
.logoOn {
color: var(--color-secondary);
font-weight: 700;
}
/* Toggle Button Styles */
.toggleButton {
background: none;
border: none;
border-radius: 10px;
color: var(--color-text);
width: 50px;
height: 50px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s ease;
flex-shrink: 0;
position: absolute;
right: 20px;
top: 50%;
transform: translateY(-50%);
}
.toggleButton:hover {
background: none;
transform: scale(1.05);
top: 50%;
transform: translateY(-50%);
}
/* Minimized Sidebar Styles */
.sidebarContainer.minimized {
width: 80px;
overflow: visible !important;
}
.sidebarContainer.minimized .sidebar {
width: 80px;
align-items: center;
overflow: visible !important;
overflow-y: visible !important;
overflow-x: visible !important;
}
.sidebarContainer.minimized .logoContainer {
height: 80px;
padding: 15px 10px 7px 10px;
justify-content: center;
box-sizing: border-box;
}
.sidebarContainer.minimized .logoWrapper {
opacity: 0;
left: 20px; /* Keep same left position */
top: 50%;
transform: translateY(-50%); /* Keep same vertical centering */
}
.sidebarContainer.minimized .menuText {
opacity: 0; /* Same rule as logo - fade out when sidebar minimized */
}
.sidebarContainer.minimized .hassubmenu {
opacity: 0; /* Same rule as logo - fade out when sidebar minimized */
}
.sidebarContainer.minimized .text_content {
opacity: 0; /* Same rule as logo and menu text - fade out when sidebar minimized */
}
.sidebarContainer.minimized .toggleButton {
right: 15px; /* Center in 80px width: (80-50)/2 = 15px */
top: 50%;
transform: translateY(-50%); /* Keep same vertical centering */
}

View file

@ -1,783 +0,0 @@
.menu {
display: flex;
flex-direction: column;
align-items: flex-start;
position: relative;
margin: 0;
padding: 0;
width: 250px;
min-width: 250px;
max-width: 250px;
overflow: visible; /* Allow nested submenus to expand */
/* Ensure menu expands to fit content - critical for proper sidebar expansion */
/* Don't set height - let it be determined by content */
flex-shrink: 0; /* Prevent menu from shrinking */
flex-grow: 0; /* Don't grow beyond content */
}
.menu.minimized {
position: relative !important;
}
.menu li {
display: flex;
width: 250px !important;
min-width: 250px;
max-width: 250px;
height: 44px;
padding: 0 3px 0 27px;
margin: 0;
align-items: center;
color: var(--color-text);
list-style: none;
position: relative;
gap: 8px;
box-sizing: border-box;
}
.menu li:hover, .menu li.active {
background: var(--color-secondary);
color: white;
border-top-right-radius: 25px;
border-bottom-right-radius: 25px;
}
.menu li:hover a , .menu li.active a {
color: white;
text-decoration: none;
}
.menuTextLink {
flex: 1;
text-decoration: none;
color: inherit;
display: flex;
align-items: center;
padding: 0;
margin: 0;
background: none;
border: none;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
gap: 5px;
font-family: var(--font-family);
font-size: 0.9rem;
font-style: normal;
font-weight: 500;
line-height: normal;
}
.menuTextButton {
flex: 1;
text-decoration: none;
color: inherit;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 0 0;
margin: 0;
background: none;
border: none;
cursor: pointer;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
gap: 5px;
font-family: var(--font-family);
font-size: 0.9rem;
font-style: normal;
font-weight: 500;
line-height: normal;
width: 100%;
}
.menu li:hover .menuTextButton {
color: white;
}
.arrowButton {
background: none;
border: none;
padding: 4px;
margin-right: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s ease;
flex-shrink: 0;
width: 24px;
height: 24px;
color:var(--color-text);
}
.menu li:hover .arrowButton {
color: white;
}
.arrowButton:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.icon {
display: flex;
width: 25px;
height: 25px;
padding: 2.292px 2.3px 2.508px 2.292px;
justify-content: center;
align-items: center;
flex-shrink: 0;
flex-grow: 0;
}
/* Ensure icon is visible when minimized */
.menu.minimized li .icon.iconMinimized {
display: flex !important;
opacity: 1 !important;
visibility: visible !important;
position: absolute !important;
z-index: 99999 !important;
}
.hassubmenu {
width: 20px;
height: 20px;
opacity: 1;
transition: opacity 0.3s ease-in-out;
}
.rotated {
transform: rotate(180deg);
}
.menuText {
opacity: 1;
transition: opacity 0.3s ease-in-out;
}
.minimizedOverlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: auto;
background: transparent;
}
.minimizedSubmenuToggle {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: none;
border: none;
cursor: pointer;
z-index: 1;
padding: 0;
margin: 0;
pointer-events: auto;
}
/* Minimized Menu Styles */
.menu.minimized li {
width: 46px;
padding: 0;
justify-content: center;
align-items: center;
position: relative !important;
overflow: visible !important;
clip-path: none !important;
clip: none !important;
contain: none !important;
isolation: auto !important;
/* Don't create stacking context that interferes */
transform: none !important;
}
.menu.minimized li a{
opacity: 0;
}
/* Ensure icons are never hidden when minimized */
.menu.minimized li .iconMinimized,
.menu.minimized li [data-debug="icon-minimized"] {
opacity: 1 !important;
visibility: visible !important;
display: flex !important;
position: absolute !important;
z-index: 99999 !important;
}
.iconMinimizedWrapper {
position: absolute !important;
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%) !important;
font-size: 25px !important;
line-height: 25px !important;
width: 25px !important;
height: 25px !important;
min-width: 25px !important;
min-height: 25px !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
pointer-events: none !important;
overflow: visible !important;
visibility: visible !important;
opacity: 1 !important;
flex-shrink: 0 !important;
margin: 0 !important;
z-index: 2 !important;
}
.iconMinimized {
margin: 0 !important;
display: flex !important;
justify-content: center !important;
align-items: center !important;
width: 25px !important;
height: 25px !important;
padding: 2.292px 2.3px 2.508px 2.292px !important;
color: var(--color-text) !important;
opacity: 1 !important;
visibility: visible !important;
font-size: 25px !important;
}
.iconMinimized svg {
width: 25px !important;
height: 25px !important;
min-width: 25px !important;
min-height: 25px !important;
max-width: 25px !important;
max-height: 25px !important;
display: block !important;
opacity: 1 !important;
visibility: visible !important;
}
.iconMinimized svg path {
fill: inherit !important;
stroke: inherit !important;
}
.menu.minimized li.active .iconMinimized svg,
.menu.minimized li:hover .iconMinimized svg {
color: white !important;
fill: white !important;
stroke: white !important;
}
.menu.minimized li.active .iconMinimized svg path,
.menu.minimized li:hover .iconMinimized svg path {
fill: white !important;
stroke: white !important;
}
/* Ensure submenu can expand below minimized items */
.menu.minimized {
width: 100%;
align-items: center;
overflow: visible !important;
min-height: auto !important;
height: auto !important;
}
.menu.minimized li:hover,
.menu.minimized li.active {
border-radius: 15px;
color: var(--color-bg);
}
.menu.minimized li.active .iconMinimized,
.menu.minimized li:hover .iconMinimized {
color: white !important;
}
.menu.minimized li.active .iconMinimized svg,
.menu.minimized li:hover .iconMinimized svg {
color: white !important;
fill: white !important;
}
.menu.minimized li.active .iconMinimized svg path,
.menu.minimized li:hover .iconMinimized svg path {
fill: white !important;
stroke: white !important;
}
/* Disabled item styles */
.menu.disabled,
.menu li.disabledItem {
opacity: 0.4;
pointer-events: none;
}
.menu li.disabledItem:hover {
background: transparent;
color: var(--color-text);
cursor: not-allowed;
}
.disabledLink {
color: var(--color-text) !important;
opacity: 0.6 !important;
cursor: not-allowed !important;
pointer-events: none !important;
}
.disabledText {
color: var(--color-text) !important;
opacity: 0.6 !important;
}
.disabledIcon {
opacity: 0.6 !important;
color: var(--color-text) !important;
}
.disabledArrow {
opacity: 0.6 !important;
color: var(--color-text) !important;
}
/* Ensure disabled items don't show hover effects */
.menu li.disabledItem:hover .disabledLink,
.menu li.disabledItem:hover .disabledText,
.menu li.disabledItem:hover .disabledIcon,
.menu li.disabledItem:hover .disabledArrow {
color: var(--color-text) !important;
opacity: 0.6 !important;
}
/* ============================================
Submenu Styles (merged from SidebarSubmenu.module.css)
============================================ */
.submenu {
position: relative;
border: none;
border-top-right-radius: 25px;
border-bottom-right-radius: 25px;
margin: 0;
overflow: visible; /* Allow nested submenus to expand beyond this container */
width: 250px !important;
min-width: 250px !important;
max-width: 250px !important;
box-sizing: border-box;
flex-shrink: 0;
flex-grow: 0;
display: block; /* Ensure it's part of the document flow */
/* Ensure submenu expands parent container */
height: auto;
min-height: 0;
}
/* Motion div inside submenu - allow nested submenus to expand */
.submenu > div[style*="overflow"] {
overflow: visible !important; /* Override inline overflow:hidden for nested submenus */
}
/* For nested submenus specifically, ensure they can expand */
.submenuList li .menu .submenu {
overflow: visible !important;
}
.submenuList li .menu .submenu > div {
overflow: visible !important;
}
.submenuLineContainer {
display: flex; /* Flex column - same as .sidebar for consistent behavior */
flex-direction: column; /* Stack items vertically */
align-items: flex-start; /* Match .sidebar alignment */
padding: 0;
margin: 0;
overflow: visible; /* Allow nested submenus to expand */
width: 100%; /* Full width */
}
.submenuList {
margin: 0;
flex-grow: 1;
list-style: none;
padding: 0;
overflow: visible; /* Allow nested submenus to expand beyond container */
}
.submenuList li {
width: 100%;
color: #181818;
margin: 0;
position: relative;
overflow: visible; /* Allow nested submenus to expand */
display: block; /* Block display allows nested submenus to extend properly */
}
.submenuList li a {
width: 100%;
height: 100%;
padding: 0 3px 0 27px; /* Base padding, indentation is added via inline styles on parent div */
background: none;
border: none;
text-align: left;
cursor: pointer;
font-family: var(--font-family);
font-size: 0.9rem;
color: #181818;
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
transition: background-color 0.2s ease;
}
.submenuList li a:hover {
background-color: var(--color-hover, rgba(0, 0, 0, 0.05));
}
.textContainer {
position: relative;
width: 100%;
overflow: hidden;
white-space: nowrap;
}
.submenuIcon {
width: 16px;
height: 16px;
color: #181818;
flex-shrink: 0;
}
/* Horizontal layout for minimized sidebar submenus */
.submenu.minimized {
width: 100% !important;
margin: 0;
padding: 4px 0;
position: relative;
border-radius: 0;
overflow: hidden !important;
display: flex !important;
align-items: center;
justify-content: center;
opacity: 1 !important;
visibility: visible !important;
z-index: 10 !important;
}
.submenuHorizontalContainer {
display: flex !important;
align-items: center;
justify-content: center;
width: 100%;
padding: 0;
margin: 0;
box-sizing: border-box;
opacity: 1 !important;
visibility: visible !important;
}
.submenuHorizontalList {
display: flex !important;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
list-style: none;
padding: 0;
margin: 0;
flex-wrap: wrap;
opacity: 1 !important;
visibility: visible !important;
}
.submenuHorizontalItem {
display: flex !important;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
width: auto;
height: auto;
list-style: none;
opacity: 1 !important;
visibility: visible !important;
position: relative; /* Allow nested submenus to position correctly */
}
/* Nested SidebarItem in horizontal list - ensure it renders correctly when minimized */
.submenuHorizontalItem .menu.minimized {
width: 40px !important;
min-width: 40px !important;
max-width: 40px !important;
height: 40px !important;
display: inline-flex !important;
flex-direction: row !important;
align-items: center !important;
justify-content: center !important;
padding: 0 !important;
margin: 0 !important;
}
.submenuHorizontalItem .menu.minimized li {
width: 40px !important;
min-width: 40px !important;
max-width: 40px !important;
height: 40px !important;
padding: 0 !important;
margin: 0 !important;
display: flex !important;
align-items: center !important;
justify-content: center !important;
}
/* Nested horizontal submenu should appear below the parent item */
.submenuHorizontalItem .submenuHorizontalContainer {
position: absolute;
top: 100%;
left: 0;
margin-top: 4px;
z-index: 100;
background: var(--color-bg);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 4px;
transform-origin: top left;
will-change: transform, opacity;
}
/* Smooth transitions for horizontal submenu items */
.submenuHorizontalItem {
transition: opacity 0.2s ease;
}
.submenuHorizontalLink {
transition: background-color 0.2s ease;
}
.submenuHorizontalLink {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 40px;
height: 40px;
border-radius: 12px;
background: none;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
text-decoration: none;
color: #181818 !important;
padding: 0;
margin: 0;
box-sizing: border-box;
position: relative;
overflow: visible;
opacity: 1 !important;
visibility: visible !important;
}
.submenuHorizontalLink:hover {
background-color: var(--color-secondary);
color: white;
}
.submenuHorizontalLink:hover .submenuHorizontalIcon {
color: white !important;
}
.submenuHorizontalLink:hover .submenuHorizontalIcon svg {
color: white !important;
}
.submenuHorizontalLink:hover .submenuHorizontalIcon svg path {
fill: currentColor !important;
stroke: currentColor !important;
}
.submenuHorizontalIcon {
display: block !important;
width: 16px !important;
height: 16px !important;
padding: 0;
flex-shrink: 0;
flex-grow: 0;
color: #181818 !important;
margin: 0;
box-sizing: border-box;
position: relative !important;
top: auto !important;
left: auto !important;
transform: none !important;
opacity: 1 !important;
visibility: visible !important;
}
.submenuHorizontalIcon svg {
width: 16px !important;
height: 16px !important;
min-width: 16px !important;
min-height: 16px !important;
max-width: 16px !important;
max-height: 16px !important;
display: block !important;
margin: 0;
padding: 0;
opacity: 1 !important;
visibility: visible !important;
color: #181818 !important;
}
.submenuHorizontalIcon svg path {
color: inherit !important;
fill: currentColor !important;
stroke: currentColor !important;
}
/* Ensure submenu content is visible when sidebar is minimized */
.menu.minimized .submenu.minimized,
.menu.minimized .submenu.minimized * {
opacity: 1 !important;
visibility: visible !important;
color: #181818 !important;
}
.menu.minimized .submenuHorizontalLink,
.menu.minimized .submenuHorizontalLink * {
opacity: 1 !important;
visibility: visible !important;
color: #181818 !important;
}
/* Active state for submenu items */
.submenuList li.active {
background-color: var(--color-secondary);
border-radius: 0 25px 25px 0;
}
.submenuList li.active a,
.submenuList li.active a span,
.submenuList li a.activeLink {
color: white !important;
}
.submenuList li.active .submenuIcon {
color: white !important;
}
/* Active state for horizontal (minimized) submenu items */
.submenuHorizontalItem.active .submenuHorizontalLink,
.submenuHorizontalLink.activeLink {
background-color: var(--color-secondary);
color: white !important;
}
.submenuHorizontalItem.active .submenuHorizontalIcon,
.submenuHorizontalLink.activeLink .submenuHorizontalIcon {
color: white !important;
}
.submenuHorizontalItem.active .submenuHorizontalIcon svg,
.submenuHorizontalLink.activeLink .submenuHorizontalIcon svg {
color: white !important;
}
.submenuHorizontalItem.active .submenuHorizontalIcon svg path,
.submenuHorizontalLink.activeLink .submenuHorizontalIcon svg path {
fill: white !important;
stroke: white !important;
}
/* Nested submenu toggle button (for navigation nodes without links) */
.nestedToggleButton {
width: 100%;
height: 100%;
padding: 0 3px 0 27px;
background: none;
border: none;
text-align: left;
cursor: pointer;
font-family: var(--font-family);
font-size: 0.9rem;
color: #181818;
display: flex;
align-items: center;
gap: 8px;
transition: background-color 0.2s ease;
}
.nestedToggleButton:hover {
background-color: var(--color-hover, rgba(0, 0, 0, 0.05));
}
.nestedToggleButton:focus {
outline: none;
}
/* Nested arrow icon */
.nestedArrow {
transition: transform 0.2s ease;
flex-shrink: 0;
color: #181818;
}
.nestedArrow.rotated {
transform: rotate(180deg);
}
/* Active state for nested toggle button */
.submenuList li.active .nestedToggleButton {
color: white !important;
}
.submenuList li.active .nestedArrow {
color: white !important;
}
/* Nested SidebarItem within submenu - ensure full width and allow submenu to extend */
.submenuList li .menu {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
overflow: visible !important; /* Allow nested submenus to extend */
}
.submenuList li .menu li {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
height: 44px; /* Set height only on the actual item li, not the wrapper */
display: flex !important; /* Ensure the item content displays correctly */
align-items: center !important;
}
/* Ensure nested submenus can expand and are visible */
.submenuList li .menu .submenu {
position: relative;
overflow: visible !important;
z-index: 10; /* Ensure nested submenu appears above other content */
}
/* Allow the sidebar container to expand when nested items are open */
.sidebar {
overflow-y: auto;
overflow-x: hidden;
}

View file

@ -1,353 +0,0 @@
.submenu {
position: relative;
border: none;
border-top-right-radius: 25px;
border-bottom-right-radius: 25px;
margin: 0;
overflow: visible; /* Allow nested submenus to expand beyond this container */
width: 250px !important;
min-width: 250px !important;
max-width: 250px !important;
box-sizing: border-box;
flex-shrink: 0;
flex-grow: 0;
}
/* Motion div inside submenu - allow nested submenus to expand */
.submenu > div[style*="overflow"] {
overflow: visible !important; /* Override inline overflow:hidden for nested submenus */
}
/* For nested submenus specifically, ensure they can expand */
.submenuList li .menu .submenu {
overflow: visible !important;
}
.submenuList li .menu .submenu > div {
overflow: visible !important;
}
.submenuLineContainer {
display: flex;
align-items: stretch;
gap: 0;
padding: 0;
margin: 0;
overflow: visible; /* Allow nested submenus to expand */
}
.submenuList {
margin: 0;
flex-grow: 1;
list-style: none;
padding: 0;
overflow: visible; /* Allow nested submenus to expand beyond container */
}
.submenuList li {
width: 100%;
height: 44px;
color: #181818;
margin: 0;
position: relative;
overflow: visible; /* Allow nested submenus to expand */
display: flex;
align-items: center;
}
.submenuList li a {
width: 100%;
height: 100%;
padding: 0 3px 0 27px; /* Base padding, indentation is added via inline styles on parent div */
background: none;
border: none;
text-align: left;
cursor: pointer;
font-family: var(--font-family);
font-size: 0.9rem;
color: #181818;
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
transition: background-color 0.2s ease;
}
.submenuList li a:hover {
background-color: var(--color-hover, rgba(0, 0, 0, 0.05));
}
.textContainer {
position: relative;
width: 100%;
overflow: hidden;
white-space: nowrap;
}
.submenuIcon {
width: 16px;
height: 16px;
color: #181818;
flex-shrink: 0;
}
/* Horizontal layout for minimized sidebar submenus */
.submenu.minimized {
width: 100% !important;
margin: 0;
padding: 4px 0;
position: relative;
border-radius: 0;
overflow: hidden !important;
display: flex !important;
align-items: center;
justify-content: center;
opacity: 1 !important;
visibility: visible !important;
z-index: 10 !important;
}
.submenuHorizontalContainer {
display: flex !important;
align-items: center;
justify-content: center;
width: 100%;
padding: 0;
margin: 0;
box-sizing: border-box;
opacity: 1 !important;
visibility: visible !important;
}
.submenuHorizontalList {
display: flex !important;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
list-style: none;
padding: 0;
margin: 0;
flex-wrap: wrap;
opacity: 1 !important;
visibility: visible !important;
}
.submenuHorizontalItem {
display: flex !important;
align-items: center;
justify-content: center;
margin: 0;
padding: 0;
width: auto;
height: auto;
list-style: none;
opacity: 1 !important;
visibility: visible !important;
}
.submenuHorizontalLink {
display: flex !important;
align-items: center !important;
justify-content: center !important;
width: 40px;
height: 40px;
border-radius: 12px;
background: none;
border: none;
cursor: pointer;
transition: background-color 0.2s ease;
text-decoration: none;
color: #181818 !important;
padding: 0;
margin: 0;
box-sizing: border-box;
position: relative;
overflow: visible;
opacity: 1 !important;
visibility: visible !important;
}
.submenuHorizontalLink:hover {
background-color: var(--color-secondary);
color: white;
}
.submenuHorizontalLink:hover .submenuHorizontalIcon {
color: white !important;
}
.submenuHorizontalLink:hover .submenuHorizontalIcon svg {
color: white !important;
}
.submenuHorizontalLink:hover .submenuHorizontalIcon svg path {
fill: currentColor !important;
stroke: currentColor !important;
}
.submenuHorizontalIcon {
display: block !important;
width: 16px !important;
height: 16px !important;
padding: 0;
flex-shrink: 0;
flex-grow: 0;
color: #181818 !important;
margin: 0;
box-sizing: border-box;
position: relative !important;
top: auto !important;
left: auto !important;
transform: none !important;
opacity: 1 !important;
visibility: visible !important;
}
.submenuHorizontalIcon svg {
width: 16px !important;
height: 16px !important;
min-width: 16px !important;
min-height: 16px !important;
max-width: 16px !important;
max-height: 16px !important;
display: block !important;
margin: 0;
padding: 0;
opacity: 1 !important;
visibility: visible !important;
color: #181818 !important;
}
.submenuHorizontalIcon svg path {
color: inherit !important;
fill: currentColor !important;
stroke: currentColor !important;
}
/* Ensure submenu content is visible when sidebar is minimized */
.menu.minimized .submenu.minimized,
.menu.minimized .submenu.minimized * {
opacity: 1 !important;
visibility: visible !important;
color: #181818 !important;
}
.menu.minimized .submenuHorizontalLink,
.menu.minimized .submenuHorizontalLink * {
opacity: 1 !important;
visibility: visible !important;
color: #181818 !important;
}
/* Active state for submenu items */
.submenuList li.active {
background-color: var(--color-secondary);
border-radius: 0 25px 25px 0;
}
.submenuList li.active a,
.submenuList li.active a span,
.submenuList li a.activeLink {
color: white !important;
}
.submenuList li.active .submenuIcon {
color: white !important;
}
/* Active state for horizontal (minimized) submenu items */
.submenuHorizontalItem.active .submenuHorizontalLink,
.submenuHorizontalLink.activeLink {
background-color: var(--color-secondary);
color: white !important;
}
.submenuHorizontalItem.active .submenuHorizontalIcon,
.submenuHorizontalLink.activeLink .submenuHorizontalIcon {
color: white !important;
}
.submenuHorizontalItem.active .submenuHorizontalIcon svg,
.submenuHorizontalLink.activeLink .submenuHorizontalIcon svg {
color: white !important;
}
.submenuHorizontalItem.active .submenuHorizontalIcon svg path,
.submenuHorizontalLink.activeLink .submenuHorizontalIcon svg path {
fill: white !important;
stroke: white !important;
}
/* Nested submenu toggle button (for navigation nodes without links) */
.nestedToggleButton {
width: 100%;
height: 100%;
padding: 0 3px 0 27px;
background: none;
border: none;
text-align: left;
cursor: pointer;
font-family: var(--font-family);
font-size: 0.9rem;
color: #181818;
display: flex;
align-items: center;
gap: 8px;
transition: background-color 0.2s ease;
}
.nestedToggleButton:hover {
background-color: var(--color-hover, rgba(0, 0, 0, 0.05));
}
.nestedToggleButton:focus {
outline: none;
}
/* Nested arrow icon */
.nestedArrow {
transition: transform 0.2s ease;
flex-shrink: 0;
color: #181818;
}
.nestedArrow.rotated {
transform: rotate(180deg);
}
/* Active state for nested toggle button */
.submenuList li.active .nestedToggleButton {
color: white !important;
}
.submenuList li.active .nestedArrow {
color: white !important;
}
/* Nested SidebarItem within submenu - ensure full width */
.submenuList li .menu {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
}
.submenuList li .menu li {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
}
/* Ensure nested submenus can expand and are visible */
.submenuList li .menu .submenu {
position: relative;
overflow: visible !important;
z-index: 10; /* Ensure nested submenu appears above other content */
}
/* Allow the sidebar container to expand when nested items are open */
.sidebar {
overflow-y: auto;
overflow-x: hidden;
}

View file

@ -1,323 +0,0 @@
.user_section {
display: flex;
width: 100%;
flex-direction: column;
align-items: left;
font-family: var(--font-family);
box-sizing: border-box;
position: relative;
margin-top: auto; /* Push to bottom */
margin-bottom: 0;
padding-left: 5px;
padding-bottom: 7px;
padding-right: 5px;
flex-shrink: 0;
max-height: fit-content;
overflow: visible;
contain: layout;
}
.user_info {
display: flex;
flex-direction: left;
align-items: center;
gap: 12px;
width: 100%;
border-top-right-radius: 25px;
border-bottom-right-radius : 25px;
padding: 0 3px 0 15px;
opacity: 1;
white-space: nowrap;
overflow: hidden;
transition: opacity 0.3s ease-in-out;
}
.user_info.notClickable {
cursor: pointer;
padding: 8px;
margin: -8px;
background-color: var(--color-secondary, rgba(0, 0, 0, 0.05)); /* Compensate for padding to maintain original size */
}
.user_info.clickable {
cursor: pointer;
padding: 8px;
margin: -8px;
background-color: none; /* Compensate for padding to maintain original size */
}
.user_info.clickable:hover {
background-color: var(--color-secondary, rgba(0, 0, 0, 0.05));
}
.user_header {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
transition: justify-content 0.3s ease;
padding: 0 3px 0 15px;
}
.user_avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--color-primary);
color: var(--color-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.9rem;
font-weight: 600;
text-transform: uppercase;
flex-shrink: 0;
transition: all 0.3s ease;
letter-spacing: 0.5px;
}
.text_content {
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
white-space: nowrap;
overflow: hidden;
opacity: 0; /* Start hidden */
animation: delayedFadeIn 0.3s ease-in-out forwards; /* 0.3s delay + 0.3s fade in */
}
.username {
font-size: 0.8rem !important;
color: var(--color-text) !important;
font-style: italic;
}
.logout_popup {
position: absolute;
bottom: 100%;
left: 0px;
right: 10px;
background: var(--color-primary);
border: none;
border-top-right-radius: 25px;
border-bottom-right-radius: 25px;
z-index: 10;
margin-bottom: 8px;
overflow: hidden;
}
.logout_menu_button {
width: 100%;
padding: 12px 25px;
background: none;
border: none;
text-align: left;
cursor: pointer;
font-family: var(--font-family);
font-size: 0.9rem;
color: #181818;
display: flex;
align-items: center;
gap: 8px;
}
.logout_menu_button:hover:not(:disabled) {
background-color: var(--color-hover, rgba(0, 0, 0, 0.05));
}
.logout_menu_button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.logout_icon {
font-size: 0.9rem;
color: var(--color-gray);
}
.user_section h1 {
margin: 0;
font-size: 0.9rem;
line-height: 1.;
color: var(--color-text);
font-family: var(--font-family);
font-weight: 400;
opacity: 0; /* Start hidden */
animation: delayedFadeIn 0.3s ease-in-out forwards; /* 0.3s delay + 0.3s fade in */
white-space: nowrap;
}
.user_section p {
margin: 0;
font-family: var(--font-family);
opacity: 0; /* Start hidden */
animation: delayedFadeIn 0.3s ease-in-out forwards; /* 0.3s delay + 0.3s fade in */
white-space: nowrap;
}
.userContainer {
padding: 12px;
text-align: center;
color: transparent;
background-color: transparent;
border: transparent;
}
/* Minimized User Section Styles */
.user_section.minimized {
width: 100%; /* Match menu item width */
padding: 20px 15px 8px 15px; /* Match menu item padding structure */
align-items: center;
justify-content: center;
color: transparent;
background-color: transparent;
border: transparent;
}
.user_section.minimized .user_info {
justify-content: center; /* Center the content when minimized */
width: 100%;
height: 40px;
color: transparent;
background-color: transparent;
border: transparent;
}
.user_section.minimized .user_info.clickable:hover {
background-color: transparent; /* Remove orange background on hover when minimized */
}
.user_section.minimized .user_info.notClickable {
background-color: transparent; /* Remove orange background on hover when minimized */
}
.user_section.minimized .user_header {
justify-content: center; /* Center the avatar when minimized */
width: 100%;
padding: 0; /* Remove padding to center properly */
}
.user_section.minimized .user_avatar {
width: 40px;
height: 40px;
}
.user_section.minimized .text_content {
opacity: 0;
width: 0;
color: transparent;
background-color: transparent;
border: transparent;
animation: none; /* Disable animation when minimized */
}
.user_section.minimized h1 {
opacity: 0;
color: transparent;
background-color: transparent;
border: transparent;
animation: none; /* Disable animation when minimized */
}
.user_section.minimized p {
opacity: 0;
color: transparent;
background-color: transparent;
border: transparent;
animation: none; /* Disable animation when minimized */
}
/* Minimized logout popup styles */
.logout_popup_minimized {
left: 50%; /* Center horizontally over the user icon */
bottom: calc(100% + 8px); /* Position above the user icon */
transform: translateX(-50%) translateY(20px); /* Center and start position for animation */
width: 40px; /* Same size as user avatar */
height: 40px;
margin-left: 0;
margin-bottom: 0;
border-radius: 50%; /* Make it circular */
background: var(--color-primary);
border: 1px solid var(--color-primary);
overflow: visible; /* Allow tooltip to show */
opacity: 0; /* Start invisible */
animation: flyUpFadeIn 0.3s ease-out forwards; /* Animation */
transition: opacity 0.2s ease-out; /* Smooth fade out when disappearing */
}
.logout_popup_minimized .logout_menu_button {
justify-content: center;
align-items: center;
padding: 0;
width: 100%;
height: 100%;
border-radius: 50%; /* Make button circular */
position: relative;
}
.logout_popup_minimized .logout_menu_button::after {
content: 'Abmelden';
position: absolute;
bottom: calc(100% + 8px);
left: 50%;
transform: translateX(-50%);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 0.75rem;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
z-index: 11;
}
.logout_popup_minimized .logout_menu_button:hover::after {
opacity: 1;
}
.logout_popup_minimized .logout_icon {
margin: 0;
font-size: 1rem;
}
/* Animation keyframes for delayed text appearance */
@keyframes delayedFadeIn {
0% {
opacity: 0;
}
50% {
opacity: 0; /* Stay hidden for first 50% (0.3s) */
}
100% {
opacity: 1; /* Fade in during last 50% (0.3s) */
}
}
/* Animation keyframes for fly up and fade in effect */
@keyframes flyUpFadeIn {
from {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}

View file

@ -1,196 +0,0 @@
import React, { useState, useEffect, useRef } from 'react'
import { useMsal } from '@azure/msal-react'
import { FaSignOutAlt } from 'react-icons/fa'
import styles from './SidebarStyles/SidebarUser.module.css'
import { User } from '../../hooks/useUsers'
import { SidebarUserProps } from './sidebarTypes';
import { getUserDataCache, CachedUserData } from '../../utils/userCache';
import { useCurrentUser } from '../../hooks/useUsers';
const SidebarUser: React.FC<SidebarUserProps> = ({ isMinimized = false }) => {
const { instance } = useMsal();
const { logout } = useCurrentUser(); // Only use logout function from hook
// Local state for user data created from cached data
const [user, setUser] = useState<User | null>(null);
const [userError, setUserError] = useState<string | null>(null);
const [cachedUserData, setCachedUserData] = useState<CachedUserData | null>(null);
const [showLogoutMenu, setShowLogoutMenu] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const userSectionRef = useRef<HTMLDivElement>(null);
// Function to get initials from full name
const getInitials = (fullName: string): string => {
return fullName
.split(' ')
.map(name => name.charAt(0).toUpperCase())
.join('')
.substring(0, 2); // Limit to 2 characters
};
const handleUserClick = () => {
setShowLogoutMenu(!showLogoutMenu);
};
const handleLogout = async () => {
if (!user || isLoggingOut) return;
setIsLoggingOut(true);
try {
// Pass MSAL instance for Microsoft authentication
if (user.authenticationAuthority === 'msft') {
await logout(instance);
} else {
await logout();
}
setShowLogoutMenu(false);
} catch (error) {
console.error('Logout failed:', error);
// Keep the menu open if logout fails so user can try again
setIsLoggingOut(false);
}
};
// Load cached user data on mount and when user updates
useEffect(() => {
const loadCachedUserData = () => {
const cached = getUserDataCache();
setCachedUserData(cached);
if (cached?.id) {
// Create a User object from cached data with fallback values
const userData: User = {
id: cached.id,
username: cached.username,
email: cached.email || cached.username, // Use email or username as fallback
fullName: cached.fullName || cached.username.split('@')[0] || cached.username,
language: cached.language || 'de', // Default language
enabled: cached.enabled ?? true, // Assume enabled if logged in
roleLabels: cached.roleLabels || [],
authenticationAuthority: cached.authenticationAuthority || 'local',
isSysAdmin: cached.isSysAdmin || false
};
setUser(userData);
setUserError(null);
} else {
setUser(null);
}
};
loadCachedUserData();
}, []); // Empty dependency array - only run on mount
// Listen for user updates from settings page
useEffect(() => {
const handleUserUpdate = () => {
// Refresh cached user data when user info is updated
const cached = getUserDataCache();
setCachedUserData(cached);
if (cached?.id) {
const userData: User = {
id: cached.id,
username: cached.username,
email: cached.email || cached.username,
fullName: cached.fullName || cached.username.split('@')[0] || cached.username,
language: cached.language || 'de',
enabled: cached.enabled ?? true,
roleLabels: cached.roleLabels || [],
authenticationAuthority: cached.authenticationAuthority || 'local',
isSysAdmin: cached.isSysAdmin || false
};
setUser(userData);
setUserError(null);
} else {
setUser(null);
}
};
window.addEventListener('userInfoUpdated', handleUserUpdate);
return () => {
window.removeEventListener('userInfoUpdated', handleUserUpdate);
};
}, []); // Empty dependency array - only set up listener once
// Close popup when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (userSectionRef.current && !userSectionRef.current.contains(event.target as Node)) {
setShowLogoutMenu(false);
}
};
if (showLogoutMenu) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showLogoutMenu]);
if (!cachedUserData) {
return (
<div className={`${styles.user_section} ${isMinimized ? styles.minimized : ''}`}>
<div className={styles.userContainer}>Lädt...</div>
</div>
);
}
if (userError) {
return (
<div className={`${styles.user_section} ${isMinimized ? styles.minimized : ''}`}>
<div className={styles.userContainer}>Fehler beim Laden des Benutzerprofils</div>
</div>
);
}
if (!user) {
return (
<div className={`${styles.user_section} ${isMinimized ? styles.minimized : ''}`}>
<div className={styles.userContainer}>Kein Benutzer gefunden</div>
</div>
);
}
return (
<div
ref={userSectionRef}
className={`${styles.user_section} ${isMinimized ? styles.minimized : ''}`}
>
{showLogoutMenu && (
<div className={`${styles.logout_popup} ${isMinimized ? styles.logout_popup_minimized : ''}`}>
<button
className={styles.logout_menu_button}
onClick={handleLogout}
disabled={isLoggingOut}
>
<FaSignOutAlt className={styles.logout_icon} />
{!isMinimized && (isLoggingOut ? 'Abmelden...' : 'Abmelden')}
</button>
</div>
)}
<div
className={`${styles.user_info} ${showLogoutMenu ? styles.notClickable : styles.clickable}`}
onClick={handleUserClick}
>
<div className={styles.user_header}>
<div className={styles.user_avatar}>
{getInitials(user.fullName)}
</div>
{!isMinimized && (
<div className={styles.text_content}>
<h1>{user.fullName}</h1>
<p className={styles.username}>{user.username}</p>
</div>
)}
</div>
</div>
</div>
)
}
export default SidebarUser;

View file

@ -1,7 +0,0 @@
import Sidebar from "./Sidebar";
export default Sidebar;
export { default as SidebarItem } from './SidebarItem';
export { default as SidebarUser } from './SidebarUser';
export * from './sidebarTypes';
export { useSidebarLogic } from './sidebarLogic';

View file

@ -1,86 +0,0 @@
import { useState, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { SidebarState, SidebarContextType } from './sidebarTypes';
// Custom hook for sidebar logic
export const useSidebarLogic = (): SidebarContextType => {
const location = useLocation();
// Simple React state instead of state machine
const [state, setState] = useState<SidebarState>({
openItemId: null,
isMinimized: false,
});
// Toggle a specific menu item
const toggleItem = useCallback((itemId: string) => {
setState(prevState => ({
...prevState,
openItemId: prevState.openItemId === itemId ? null : itemId,
}));
}, []);
// Close all submenus
const closeAll = useCallback(() => {
setState(prevState => ({
...prevState,
openItemId: null,
}));
}, []);
// Check if a specific item is open
const isItemOpen = useCallback((itemId: string) => {
return state.openItemId === itemId;
}, [state.openItemId]);
// Check if an item is the active route
// Supports exact match and prefix match (for parent items when child route is active)
const isItemActive = useCallback((itemPath?: string) => {
if (!itemPath) return false;
const currentPath = location.pathname;
// Exact match
if (currentPath === itemPath) return true;
// Prefix match: check if current path starts with the item path
// This highlights parent items when a child/subpage is active
// Ensure we match at path segment boundaries (e.g., /admin matches /admin/users but not /administrator)
if (currentPath.startsWith(itemPath)) {
// Check if the next character is either '/' or end of string
const nextChar = currentPath[itemPath.length];
if (nextChar === '/' || nextChar === undefined) {
return true;
}
}
return false;
}, [location.pathname]);
// Minimize sidebar
const minimizeSidebar = useCallback(() => {
setState(prevState => ({
...prevState,
isMinimized: true,
// Keep submenus open when minimizing - submenu state is independent
}));
}, []);
// Expand sidebar
const expandSidebar = useCallback(() => {
setState(prevState => ({
...prevState,
isMinimized: false,
}));
}, []);
return {
state,
toggleItem,
closeAll,
isItemOpen,
isItemActive,
minimizeSidebar,
expandSidebar,
};
};

View file

@ -1,62 +0,0 @@
import React from 'react';
// Base sidebar item interface
export interface SidebarItemData {
id: string;
name: string;
link?: string;
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
submenu?: SidebarSubmenuItemData[];
moduleEnabled?: boolean; // New property for module state
depth?: number; // Hierarchy depth for indentation (0 = top level)
}
// Submenu item interface - supports recursive nesting
export interface SidebarSubmenuItemData {
id: string;
name: string;
link?: string; // Optional - if undefined, it's a navigation node (not a page)
icon?: React.ComponentType<React.SVGProps<SVGSVGElement>>;
submenu?: SidebarSubmenuItemData[]; // Recursive support for nested submenus
depth?: number; // Hierarchy depth for indentation (0 = top level)
}
// Sidebar state interface
export interface SidebarState {
openItemId: string | null;
isMinimized: boolean;
}
// Sidebar context interface
export interface SidebarContextType {
state: SidebarState;
toggleItem: (itemId: string) => void;
closeAll: () => void;
isItemOpen: (itemId: string) => boolean;
isItemActive: (itemPath?: string) => boolean;
minimizeSidebar: () => void;
expandSidebar: () => void;
}
// Component props interfaces
export interface SidebarProps {
data: SidebarItemData[];
}
export interface SidebarItemProps {
item: SidebarItemData;
isOpen: boolean;
onToggle: () => void;
isActive: boolean;
isMinimized: boolean;
}
export interface SidebarSubmenuProps {
item: SidebarItemData;
isOpen: boolean;
isMinimized?: boolean;
}
export interface SidebarUserProps {
isMinimized?: boolean;
}

View file

@ -1,481 +0,0 @@
import { useState, useEffect } from 'react';
import { IoIosBusiness, IoIosContact, IoIosTime, IoIosRefresh } from 'react-icons/io';
import sharedStyles from '../../core/PageManager/pages.module.css';
import styles from './SpeechSettings.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
interface MandateData {
id: string;
mandate_general: {
company_name: string;
industry: string;
contact_info: {
email: string;
phone: string;
street: string;
postal_code: string;
city: string;
country: string;
};
business_hours: string;
timezone: string;
};
setup_contacts: boolean;
}
interface SpeechSettingsProps {
onDataUpdate?: (data: MandateData) => void;
}
function SpeechSettings({ onDataUpdate }: SpeechSettingsProps) {
const { t } = useLanguage();
const [formData, setFormData] = useState<MandateData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [saveMessage, setSaveMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
const [focusedFields, setFocusedFields] = useState<Set<string>>(new Set());
// Load data from localStorage on component mount
useEffect(() => {
const loadSpeechData = () => {
try {
const savedData = localStorage.getItem('speechSignUpData');
const timestamp = localStorage.getItem('speechSignUpTimestamp');
if (savedData && timestamp) {
const parsedData = JSON.parse(savedData);
const savedTime = parseInt(timestamp);
const now = Date.now();
const twentyFourHours = 24 * 60 * 60 * 1000;
// Check if data is still valid (within 24 hours)
if (now - savedTime < twentyFourHours) {
setFormData(parsedData);
} else {
// Data expired, clear it
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
}
}
} catch (error) {
console.error('Error loading speech data:', error);
} finally {
setIsLoading(false);
}
};
loadSpeechData();
}, []);
const handleInputChange = (field: string, value: string) => {
if (!formData) return;
const newData = { ...formData };
const fieldParts = field.split('.');
if (fieldParts.length === 2) {
// Handle nested fields like mandate_general.company_name
const [parent, child] = fieldParts;
if (parent === 'mandate_general' && child in newData.mandate_general) {
(newData.mandate_general as any)[child] = value;
}
} else if (fieldParts.length === 3) {
// Handle deeply nested fields like mandate_general.contact_info.email
const [parent, child, grandchild] = fieldParts;
if (parent === 'mandate_general' && child === 'contact_info' && grandchild in newData.mandate_general.contact_info) {
(newData.mandate_general.contact_info as any)[grandchild] = value;
}
} else if (field === 'setup_contacts') {
newData.setup_contacts = value === 'true';
}
setFormData(newData);
setSaveMessage(null);
};
const handleFocus = (field: string) => {
setFocusedFields(prev => new Set(prev).add(field));
};
const handleBlur = (field: string) => {
setFocusedFields(prev => {
const newSet = new Set(prev);
newSet.delete(field);
return newSet;
});
};
const handleSave = async () => {
if (!formData) return;
setIsSaving(true);
try {
// Save to localStorage
localStorage.setItem('speechSignUpData', JSON.stringify(formData));
localStorage.setItem('speechSignUpTimestamp', Date.now().toString());
// Dispatch event to notify other components
window.dispatchEvent(new CustomEvent('speechSignUpChanged'));
setSaveMessage({ type: 'success', text: t('speech.settings.save_success') });
// Notify parent component if callback provided
if (onDataUpdate) {
onDataUpdate(formData);
}
// Clear message after 3 seconds
setTimeout(() => setSaveMessage(null), 3000);
} catch (error) {
console.error('Error saving speech settings:', error);
setSaveMessage({ type: 'error', text: t('speech.settings.save_error') });
} finally {
setIsSaving(false);
}
};
const handleReset = () => {
// Direct reset - user clicked the reset button intentionally
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
window.dispatchEvent(new CustomEvent('speechSignUpChanged'));
setFormData(null);
setSaveMessage({ type: 'success', text: t('speech.settings.reset_success') });
setTimeout(() => setSaveMessage(null), 3000);
};
if (isLoading) {
return (
<div className={styles.container}>
<div className={styles.loading}>
{t('common.loading')}
</div>
</div>
);
}
if (!formData) {
return (
<div className={styles.container}>
<div className={styles.noData}>
<p>{t('speech.settings.no_data')}</p>
<button
className={sharedStyles.primaryButton}
onClick={() => window.location.href = '/speech'}
>
{t('speech.settings.sign_up_now')}
</button>
</div>
</div>
);
}
return (
<div className={styles.container}>
<div className={styles.header}>
<h2 className={styles.title}>{t('speech.settings.title')}</h2>
<p className={styles.description}>{t('speech.settings.description')}</p>
</div>
{saveMessage && (
<div className={`${styles.message} ${saveMessage.type === 'success' ? styles.successMessage : styles.errorMessage}`}>
{saveMessage.text}
</div>
)}
<div className={styles.form}>
{/* Company Information Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<IoIosBusiness className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('speech.settings.company_info')}</h3>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="company_name"
className={styles.formInput}
value={formData.mandate_general.company_name}
onChange={(e) => handleInputChange('mandate_general.company_name', e.target.value)}
onFocus={() => handleFocus('company_name')}
onBlur={() => handleBlur('company_name')}
required
/>
<label
htmlFor="company_name"
className={`${styles.floatingLabel} ${formData.mandate_general.company_name || focusedFields.has('company_name') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.company_name')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="industry"
className={styles.formInput}
value={formData.mandate_general.industry}
onChange={(e) => handleInputChange('mandate_general.industry', e.target.value)}
onFocus={() => handleFocus('industry')}
onBlur={() => handleBlur('industry')}
required
/>
<label
htmlFor="industry"
className={`${styles.floatingLabel} ${formData.mandate_general.industry || focusedFields.has('industry') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.industry')} *
</label>
</div>
</div>
</div>
</div>
{/* Contact Information Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<IoIosContact className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('speech.settings.contact_info')}</h3>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="email"
id="email"
className={styles.formInput}
value={formData.mandate_general.contact_info.email}
onChange={(e) => handleInputChange('mandate_general.contact_info.email', e.target.value)}
onFocus={() => handleFocus('email')}
onBlur={() => handleBlur('email')}
required
/>
<label
htmlFor="email"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.email || focusedFields.has('email') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.email')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="tel"
id="phone"
className={styles.formInput}
value={formData.mandate_general.contact_info.phone}
onChange={(e) => handleInputChange('mandate_general.contact_info.phone', e.target.value)}
onFocus={() => handleFocus('phone')}
onBlur={() => handleBlur('phone')}
required
/>
<label
htmlFor="phone"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.phone || focusedFields.has('phone') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.phone')} *
</label>
</div>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="street"
className={styles.formInput}
value={formData.mandate_general.contact_info.street}
onChange={(e) => handleInputChange('mandate_general.contact_info.street', e.target.value)}
onFocus={() => handleFocus('street')}
onBlur={() => handleBlur('street')}
required
/>
<label
htmlFor="street"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.street || focusedFields.has('street') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.street')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="postal_code"
className={styles.formInput}
value={formData.mandate_general.contact_info.postal_code}
onChange={(e) => handleInputChange('mandate_general.contact_info.postal_code', e.target.value)}
onFocus={() => handleFocus('postal_code')}
onBlur={() => handleBlur('postal_code')}
required
/>
<label
htmlFor="postal_code"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.postal_code || focusedFields.has('postal_code') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.postal_code')} *
</label>
</div>
</div>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="city"
className={styles.formInput}
value={formData.mandate_general.contact_info.city}
onChange={(e) => handleInputChange('mandate_general.contact_info.city', e.target.value)}
onFocus={() => handleFocus('city')}
onBlur={() => handleBlur('city')}
required
/>
<label
htmlFor="city"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.city || focusedFields.has('city') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.city')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="country"
className={styles.formInput}
value={formData.mandate_general.contact_info.country}
onChange={(e) => handleInputChange('mandate_general.contact_info.country', e.target.value)}
onFocus={() => handleFocus('country')}
onBlur={() => handleBlur('country')}
required
/>
<label
htmlFor="country"
className={`${styles.floatingLabel} ${formData.mandate_general.contact_info.country || focusedFields.has('country') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.country')} *
</label>
</div>
</div>
</div>
</div>
{/* Business Hours Section */}
<div className={styles.section}>
<div className={styles.sectionHeader}>
<IoIosTime className={styles.sectionIcon} />
<h3 className={styles.sectionTitle}>{t('speech.settings.business_hours')}</h3>
</div>
<div className={styles.formRow}>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<input
type="text"
id="business_hours"
className={styles.formInput}
value={formData.mandate_general.business_hours}
onChange={(e) => handleInputChange('mandate_general.business_hours', e.target.value)}
onFocus={() => handleFocus('business_hours')}
onBlur={() => handleBlur('business_hours')}
required
/>
<label
htmlFor="business_hours"
className={`${styles.floatingLabel} ${formData.mandate_general.business_hours || focusedFields.has('business_hours') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.business_hours')} *
</label>
</div>
</div>
<div className={styles.formField}>
<div className={styles.inputContainer}>
<select
id="timezone"
className={styles.formSelect}
value={formData.mandate_general.timezone}
onChange={(e) => handleInputChange('mandate_general.timezone', e.target.value)}
onFocus={() => handleFocus('timezone')}
onBlur={() => handleBlur('timezone')}
required
>
<option value="">{t('speech.signup.select_timezone')}</option>
<option value="UTC-12">UTC-12 (Baker Island)</option>
<option value="UTC-11">UTC-11 (American Samoa)</option>
<option value="UTC-10">UTC-10 (Hawaii)</option>
<option value="UTC-9">UTC-9 (Alaska)</option>
<option value="UTC-8">UTC-8 (Pacific Time)</option>
<option value="UTC-7">UTC-7 (Mountain Time)</option>
<option value="UTC-6">UTC-6 (Central Time)</option>
<option value="UTC-5">UTC-5 (Eastern Time)</option>
<option value="UTC-4">UTC-4 (Atlantic Time)</option>
<option value="UTC-3">UTC-3 (Brazil)</option>
<option value="UTC-2">UTC-2 (Mid-Atlantic)</option>
<option value="UTC-1">UTC-1 (Azores)</option>
<option value="UTC+0">UTC+0 (Greenwich Mean Time)</option>
<option value="UTC+1">UTC+1 (Central European Time)</option>
<option value="UTC+2">UTC+2 (Eastern European Time)</option>
<option value="UTC+3">UTC+3 (Moscow Time)</option>
<option value="UTC+4">UTC+4 (Gulf Standard Time)</option>
<option value="UTC+5">UTC+5 (Pakistan Standard Time)</option>
<option value="UTC+6">UTC+6 (Bangladesh Standard Time)</option>
<option value="UTC+7">UTC+7 (Indochina Time)</option>
<option value="UTC+8">UTC+8 (China Standard Time)</option>
<option value="UTC+9">UTC+9 (Japan Standard Time)</option>
<option value="UTC+10">UTC+10 (Australian Eastern Time)</option>
<option value="UTC+11">UTC+11 (Solomon Islands)</option>
<option value="UTC+12">UTC+12 (New Zealand)</option>
</select>
<label
htmlFor="timezone"
className={`${styles.floatingLabel} ${formData.mandate_general.timezone || focusedFields.has('timezone') ? styles.floatingLabelActive : ''}`}
>
{t('speech.signup.timezone')} *
</label>
</div>
</div>
</div>
</div>
{/* Actions */}
<div className={styles.actions}>
<button
className={sharedStyles.secondaryButton}
onClick={handleReset}
disabled={isSaving}
>
<IoIosRefresh className={styles.resetIcon} />
{t('speech.settings.reset')}
</button>
<button
className={sharedStyles.primaryButton}
onClick={handleSave}
disabled={isSaving}
>
{isSaving ? t('speech.settings.saving') : t('speech.settings.save')}
</button>
</div>
</div>
</div>
);
}
export default SpeechSettings;

View file

@ -52,5 +52,4 @@ export interface CreateButtonProps extends BaseButtonProps {
iconPosition?: 'left' | 'right';
onSuccess?: (result: any) => void;
onError?: (error: string) => void;
multiStep?: boolean; // Enable multi-step form mode
}

View file

@ -4,277 +4,8 @@ import Button from '../Button';
import { Popup } from '../../Popup';
import { FormGeneratorForm, AttributeDefinition } from '../../../FormGenerator/FormGeneratorForm';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { TextField } from '../../TextField';
import { PekProvider } from '../../../../contexts/PekContext';
import { MapView } from '../../MapView';
import { usePekContext } from '../../../../contexts/PekContext';
import { FaLocationArrow, FaTimes } from 'react-icons/fa';
import { IoMdSend } from 'react-icons/io';
import styles from './CreateButton.module.css';
// Step 2 component for parcel selection (must be inside PekProvider)
const Step2Content: React.FC<{
onNext: (data: any) => void;
onBack: () => void;
addressData: {
street: string;
postalCode: string;
city: string;
};
onAddressChange: (field: string, value: string) => void;
}> = ({ onNext, onBack, addressData, onAddressChange }) => {
const { t } = useLanguage();
const {
selectedParcels,
searchParcel,
useCurrentLocation,
isGettingLocation,
isSearchingParcel,
setAdresse,
mapCenter,
mapZoomBounds,
parcelGeometries,
handleMapClick,
handleParcelClick,
removeParcel,
setIsPanelOpen
} = usePekContext();
const [step2Errors, setStep2Errors] = useState<Record<string, string>>({});
// Prevent panel from opening when parcel is clicked
React.useEffect(() => {
setIsPanelOpen(false);
}, [selectedParcels, setIsPanelOpen]);
// Build location string from address fields
const buildLocationString = () => {
const parts = [];
if (addressData.street.trim()) parts.push(addressData.street.trim());
if (addressData.postalCode.trim()) parts.push(addressData.postalCode.trim());
if (addressData.city.trim()) parts.push(addressData.city.trim());
return parts.join(', ');
};
const handleSearch = async () => {
const locationString = buildLocationString();
if (locationString.trim()) {
// Update the adresse field in context for consistency
setAdresse(locationString);
await searchParcel(locationString.trim(), true);
}
};
const handleUseCurrentLocation = async () => {
await useCurrentLocation();
};
const handleNext = () => {
const newErrors: Record<string, string> = {};
if (!addressData.street.trim()) {
newErrors.street = t('formgen.form.required', 'Strasse und Hausnummer ist erforderlich');
}
if (!addressData.postalCode.trim()) {
newErrors.postalCode = t('formgen.form.required', 'Postleitzahl ist erforderlich');
}
if (!addressData.city.trim()) {
newErrors.city = t('formgen.form.required', 'Stadt ist erforderlich');
}
if (!selectedParcels || selectedParcels.length === 0) {
newErrors.parcel = t('formgen.form.required', 'Bitte wählen Sie mindestens eine Parzelle aus');
}
setStep2Errors(newErrors);
if (Object.keys(newErrors).length === 0) {
onNext({
address: addressData,
parzellen: selectedParcels
});
}
};
return (
<div className={styles.step2Container}>
<div className={styles.stepIndicator}>
<span className={styles.stepNumber}>2</span>
<span className={styles.stepLabel}>Parzelle hinzufügen</span>
</div>
<div className={styles.addressFields}>
<TextField
value={addressData.street}
onChange={(value) => onAddressChange('street', value)}
label="Strasse und Hausnummer"
placeholder="z.B. Bundesplatz 3"
required
error={step2Errors.street}
size="md"
type="text"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
}}
/>
<div className={styles.addressRow}>
<TextField
value={addressData.postalCode}
onChange={(value) => onAddressChange('postalCode', value)}
label="Postleitzahl"
placeholder="z.B. 3000"
required
error={step2Errors.postalCode}
size="md"
type="text"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
}}
/>
<TextField
value={addressData.city}
onChange={(value) => onAddressChange('city', value)}
label="Stadt"
placeholder="z.B. Bern"
required
error={step2Errors.city}
size="md"
type="text"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
}}
/>
</div>
</div>
{/* Search buttons */}
<div className={styles.searchButtons}>
<Button
variant="primary"
size="md"
icon={IoMdSend}
onClick={handleSearch}
disabled={!buildLocationString().trim() || isGettingLocation || isSearchingParcel}
loading={isSearchingParcel}
>
Suchen
</Button>
<Button
variant="secondary"
size="md"
icon={FaLocationArrow}
onClick={handleUseCurrentLocation}
disabled={isGettingLocation || isSearchingParcel}
loading={isGettingLocation}
>
Meine Position
</Button>
</div>
<div className={styles.mapSection}>
<div className={styles.mapContainer}>
<MapView
parcels={parcelGeometries}
center={mapCenter || undefined}
zoomBounds={mapZoomBounds || undefined}
onMapClick={handleMapClick}
onParcelClick={handleParcelClick}
height="400px"
emptyMessage="Klicken Sie auf die Karte, um einen Standort auszuwählen, oder suchen Sie nach einer Adresse oben."
/>
</div>
{/* Selected parcels list displayed below map */}
{selectedParcels && selectedParcels.length > 0 && (
<div className={styles.parcelInfo}>
<h3 className={styles.parcelInfoTitle}>Ausgewählte Parzellen ({selectedParcels.length})</h3>
<div className={styles.selectedParcelsList}>
{selectedParcels.map((selectedParcel, index) => (
<div key={selectedParcel.parcel.id || index} className={styles.selectedParcelCard}>
<div className={styles.selectedParcelHeader}>
<h4 className={styles.selectedParcelTitle}>
Parzelle {index + 1}: {selectedParcel.parcel.number || selectedParcel.parcel.id || 'Unbekannt'}
</h4>
<button
className={styles.removeParcelButton}
onClick={() => removeParcel(selectedParcel.parcel.id)}
title="Parzelle entfernen"
>
<FaTimes />
</button>
</div>
<div className={styles.parcelInfoGrid}>
{selectedParcel.parcel.id && (
<div className={styles.parcelInfoItem}>
<span className={styles.parcelInfoLabel}>ID:</span>
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.id}</span>
</div>
)}
{selectedParcel.parcel.number && (
<div className={styles.parcelInfoItem}>
<span className={styles.parcelInfoLabel}>Nummer:</span>
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.number}</span>
</div>
)}
{selectedParcel.parcel.egrid && (
<div className={styles.parcelInfoItem}>
<span className={styles.parcelInfoLabel}>EGRID:</span>
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.egrid}</span>
</div>
)}
{selectedParcel.parcel.address && (
<div className={styles.parcelInfoItem}>
<span className={styles.parcelInfoLabel}>Adresse:</span>
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.address}</span>
</div>
)}
{selectedParcel.parcel.area_m2 && (
<div className={styles.parcelInfoItem}>
<span className={styles.parcelInfoLabel}>Fläche (m²):</span>
<span className={styles.parcelInfoValue}>{selectedParcel.parcel.area_m2}</span>
</div>
)}
</div>
</div>
))}
</div>
</div>
)}
{step2Errors.parcel && (
<span className={styles.errorText}>{step2Errors.parcel}</span>
)}
</div>
<div className={styles.buttonGroup}>
<Button
variant="secondary"
size="md"
onClick={onBack}
>
{t('common.back', 'Zurück')}
</Button>
<Button
variant="primary"
size="md"
onClick={handleNext}
>
{t('common.finish', 'Abschließen')}
</Button>
</div>
</div>
);
};
const CreateButton: React.FC<CreateButtonProps> = ({
onCreate,
fields,
@ -290,31 +21,14 @@ const CreateButton: React.FC<CreateButtonProps> = ({
size = 'md',
onSuccess,
onError,
multiStep = false,
...props
}) => {
const { t } = useLanguage();
const [isCreating, setIsCreating] = useState(false);
const [isPopupOpen, setIsPopupOpen] = useState(false);
const [currentStep, setCurrentStep] = useState<1 | 2>(1);
const [step1Data, setStep1Data] = useState<any>({});
const [addressData, setAddressData] = useState({
street: '',
postalCode: '',
city: ''
});
// Filter fields for multi-step: Step 1 only shows "label" field
const step1Fields = useMemo(() => {
if (multiStep) {
return fields.filter(field => field.key === 'label');
}
return fields;
}, [fields, multiStep]);
// Convert CreateButtonFieldConfig to AttributeDefinition format
const attributes: AttributeDefinition[] = useMemo(() => {
const fieldsToUse = multiStep ? step1Fields : fields;
return fieldsToUse.map(field => {
// Convert options to AttributeOption[] format
let options: AttributeDefinition['options'] = undefined;
@ -371,12 +85,11 @@ const CreateButton: React.FC<CreateButtonProps> = ({
options: options
};
});
}, [fields, multiStep, step1Fields]);
}, [fields]);
// Initialize form data with default values
const initialFormData = useMemo(() => {
const data: any = {};
const fieldsToUse = multiStep ? step1Fields : fields;
fieldsToUse.forEach(field => {
if (field.type === 'multiselect') {
// Multiselect fields should default to empty array
@ -390,116 +103,15 @@ const CreateButton: React.FC<CreateButtonProps> = ({
}
});
return data;
}, [fields, multiStep, step1Fields]);
}, [fields]);
const handleButtonClick = () => {
if (!disabled && !loading && !isCreating) {
setIsPopupOpen(true);
// Reset to step 1 when opening popup
if (multiStep) {
setCurrentStep(1);
setStep1Data({});
setAddressData({ street: '', postalCode: '', city: '' });
}
}
};
const handleStep1Next = (formData: any) => {
// Validate label is present
if (!formData.label || !formData.label.trim()) {
return; // FormGeneratorForm will show validation error
}
setStep1Data(formData);
setCurrentStep(2);
};
const handleStep2Back = () => {
setCurrentStep(1);
};
const handleStep2Finish = async (step2FormData: any) => {
// Combine step 1 and step 2 data
const selectedParcels = step2FormData.parzellen || [];
const completeData: any = {
label: step1Data.label,
// mandateId is NOT included - will be set by backend
};
// Add parzellen array if parcels were selected - include ALL parcel information for each
if (selectedParcels && selectedParcels.length > 0) {
completeData.parzellen = selectedParcels.map((selectedParcel: any) => ({
// Basic parcel info
id: selectedParcel.parcel.id,
egrid: selectedParcel.parcel.egrid,
number: selectedParcel.parcel.number,
name: selectedParcel.parcel.name,
identnd: selectedParcel.parcel.identnd,
canton: selectedParcel.parcel.canton,
municipality_code: selectedParcel.parcel.municipality_code,
municipality_name: selectedParcel.parcel.municipality_name,
address: selectedParcel.parcel.address,
area_m2: selectedParcel.parcel.area_m2,
centroid: selectedParcel.parcel.centroid,
geoportal_url: selectedParcel.parcel.geoportal_url,
realestate_type: selectedParcel.parcel.realestate_type,
// User-entered address fields (from step 2)
userAddress: {
street: step2FormData.address.street,
postalCode: step2FormData.address.postalCode,
city: step2FormData.address.city
},
// Geometry and map data
geometry: selectedParcel.map_view?.geometry_geojson,
perimeter: selectedParcel.parcel.perimeter,
map_view: selectedParcel.map_view,
// Adjacent parcels
adjacent_parcels: selectedParcel.adjacent_parcels || [],
// Include any other parcel properties that might exist
...selectedParcel.parcel
}));
}
// Send request to backend via onCreate handler
setIsCreating(true);
try {
const result = await onCreate(completeData);
if (result?.success !== false) {
// Success - close popup
setIsPopupOpen(false);
setCurrentStep(1);
setStep1Data({});
setAddressData({ street: '', postalCode: '', city: '' });
if (onSuccess) {
onSuccess(result);
}
} else {
// Handle error
if (onError) {
onError(result?.error || 'Projekt konnte nicht erstellt werden');
}
}
} catch (error: any) {
console.error('Project creation failed:', error);
if (onError) {
onError(error.message || 'Projekt konnte nicht erstellt werden');
}
} finally {
setIsCreating(false);
}
};
const handleSave = async (updatedData: any) => {
if (multiStep && currentStep === 1) {
handleStep1Next(updatedData);
return;
}
setIsCreating(true);
try {
@ -508,11 +120,6 @@ const CreateButton: React.FC<CreateButtonProps> = ({
if (result?.success !== false) {
// Success
setIsPopupOpen(false);
if (multiStep) {
setCurrentStep(1);
setStep1Data({});
setAddressData({ street: '', postalCode: '', city: '' });
}
if (onSuccess) {
onSuccess(result);
}
@ -534,18 +141,6 @@ const CreateButton: React.FC<CreateButtonProps> = ({
const handleCancel = () => {
setIsPopupOpen(false);
if (multiStep) {
setCurrentStep(1);
setStep1Data({});
setAddressData({ street: '', postalCode: '', city: '' });
}
};
const handleAddressChange = (field: string, value: string) => {
setAddressData(prev => ({
...prev,
[field]: value
}));
};
const isDisabled = disabled || loading || isCreating;
@ -590,47 +185,18 @@ const CreateButton: React.FC<CreateButtonProps> = ({
isOpen={isPopupOpen}
title={resolvedPopupTitle}
onClose={handleCancel}
size={multiStep ? 'large' : popupSize}
size={popupSize}
closable={!isCreating}
>
{multiStep ? (
currentStep === 1 ? (
<div className={styles.step1Container}>
<div className={styles.stepIndicator}>
<span className={styles.stepNumber}>1</span>
<span className={styles.stepLabel}>Titel festlegen</span>
</div>
<FormGeneratorForm
attributes={resolvedAttributes}
data={initialFormData}
mode="create"
onSubmit={handleSave}
onCancel={handleCancel}
submitButtonText={t('common.next', 'Weiter')}
cancelButtonText={t('common.cancel', 'Abbrechen')}
/>
</div>
) : (
<PekProvider>
<Step2Content
onNext={handleStep2Finish}
onBack={handleStep2Back}
addressData={addressData}
onAddressChange={handleAddressChange}
/>
</PekProvider>
)
) : (
<FormGeneratorForm
attributes={resolvedAttributes}
data={initialFormData}
mode="create"
onSubmit={handleSave}
onCancel={handleCancel}
submitButtonText={t('common.create', 'Create')}
cancelButtonText={t('common.cancel', 'Cancel')}
/>
)}
<FormGeneratorForm
attributes={resolvedAttributes}
data={initialFormData}
mode="create"
onSubmit={handleSave}
onCancel={handleCancel}
submitButtonText={t('common.create', 'Create')}
cancelButtonText={t('common.cancel', 'Cancel')}
/>
</Popup>
</>
);

View file

@ -1,76 +0,0 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { usePek } from '../hooks/usePek';
interface PekContextType {
// Location input - separate fields
kanton: string;
setKanton: (value: string) => void;
gemeinde: string;
setGemeinde: (value: string) => void;
adresse: string;
setAdresse: (value: string) => void;
buildLocationString: () => string;
// Legacy locationInput for backward compatibility
locationInput: string;
setLocationInput: (value: string) => void;
useCurrentLocation: () => Promise<void>;
isGettingLocation: boolean;
locationError: string | null;
// Parcel search
selectedParcels: any[];
searchParcel: (location: string, includeAdjacent?: boolean) => Promise<any>;
isSearchingParcel: boolean;
parcelSearchError: string | null;
removeParcel: (parcelId: string) => void;
clearSelectedParcels: () => void;
isParcelSelected: (parcelId: string) => boolean;
// Map view
mapCenter: any;
mapZoomBounds: any;
parcelGeometries: any[];
handleMapClick: (point: any) => Promise<void>;
handleParcelClick: (parcelId: string) => Promise<void>;
// Command processing
commandInput: string;
setCommandInput: (value: string) => void;
processCommand: (userInput: string) => Promise<any>;
isProcessingCommand: boolean;
commandResults: any[];
commandError: string | null;
// Project management
currentProjekt: any;
createProjekt: (data: any) => Promise<any>;
isCreatingProjekt: boolean;
addParcelToProjekt: (projektId: string, data: any) => Promise<any>;
isAddingParcel: boolean;
projektError: string | null;
// Panel state
isPanelOpen: boolean;
setIsPanelOpen: (open: boolean) => void;
}
const PekContext = createContext<PekContextType | undefined>(undefined);
export const PekProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const pekData = usePek();
return (
<PekContext.Provider value={pekData}>
{children}
</PekContext.Provider>
);
};
export const usePekContext = (): PekContextType => {
const context = useContext(PekContext);
if (!context) {
throw new Error('usePekContext must be used within a PekProvider');
}
return context;
};

View file

@ -1,52 +0,0 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { usePekTables } from '../hooks/usePekTables';
interface PekTablesContextType {
// Tables list
tables: any[];
isLoadingTables: boolean;
tablesError: string | null;
loadTables: () => Promise<void>;
// Selected table
selectedTable: string;
setSelectedTable: (value: string) => void;
tableData: any[];
isLoadingTableData: boolean;
tableDataError: string | null;
pagination: any;
loadTableData: (tableName: string, page?: number, pageSize?: number, sort?: Array<{ field: string; direction: 'asc' | 'desc' }>) => Promise<void>;
refreshTableData: () => void;
// Commands
commandInput: string;
setCommandInput: (value: string) => void;
processCommand: (userInput: string) => Promise<any>;
isProcessingCommand: boolean;
commandError: string | null;
messages: any[];
// Create record
createRecord: (tableName: string, data: any) => Promise<any>;
}
const PekTablesContext = createContext<PekTablesContextType | undefined>(undefined);
export const PekTablesProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const pekTablesData = usePekTables();
return (
<PekTablesContext.Provider value={pekTablesData}>
{children}
</PekTablesContext.Provider>
);
};
export const usePekTablesContext = (): PekTablesContextType => {
const context = useContext(PekTablesContext);
if (!context) {
throw new Error('usePekTablesContext must be used within a PekTablesProvider');
}
return context;
};

View file

@ -1,286 +0,0 @@
import React, { useEffect, useState, Suspense } from 'react';
import { useLocation } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import { getPageDataByPath, GenericPageData, PageInstance } from './data';
import PageRenderer from './PageRenderer';
import { usePermissions } from '../../hooks/usePermissions';
interface PageManagerProps {
loadingComponent: React.ComponentType;
errorComponent: React.ComponentType;
}
const PageManager: React.FC<PageManagerProps> = ({
loadingComponent: LoadingComponent,
errorComponent: ErrorComponent
}) => {
const location = useLocation();
const [pageInstances, setPageInstances] = useState<Map<string, PageInstance>>(new Map());
const { canView } = usePermissions();
// Get current path
const getCurrentPath = () => {
const path = location.pathname === '/' ? '' : location.pathname;
return path.startsWith('/') ? path.slice(1) : path;
};
const currentPath = getCurrentPath();
// Check if user has access to a page using backend RBAC permissions
const checkPageAccess = async (pageData: GenericPageData): Promise<boolean> => {
console.log('🔍 PageManager: Checking page access:', {
path: pageData.path,
name: pageData.name,
hide: pageData.hide,
moduleEnabled: pageData.moduleEnabled
});
try {
const hasAccess = await canView('UI', pageData.path);
console.log('🔍 PageManager: Page access result:', {
path: pageData.path,
hasAccess
});
return hasAccess;
} catch (error) {
console.error(`❌ PageManager: Error checking RBAC access for ${pageData.path}:`, error);
return false;
}
};
useEffect(() => {
console.log('🔄 PageManager: useEffect triggered for path:', currentPath);
const pageData = getPageDataByPath(currentPath);
console.log('📄 PageManager: Page data found:', {
path: currentPath,
hasPageData: !!pageData,
hide: pageData?.hide,
moduleEnabled: pageData?.moduleEnabled,
name: pageData?.name
});
if (!pageData || pageData.hide || !pageData.moduleEnabled) {
console.log('⛔ PageManager: Page not rendered:', {
path: currentPath,
reason: !pageData ? 'not found' : pageData.hide ? 'hidden' : 'module disabled'
});
return;
}
// Check page access (RBAC + privilegeChecker)
console.log('🔍 PageManager: Checking access before rendering:', currentPath);
// First check client-side privilegeChecker if provided
const checkPrivilege = async (): Promise<boolean> => {
if (pageData.privilegeChecker) {
try {
const result = await pageData.privilegeChecker();
if (!result) {
console.log('⛔ PageManager: Page blocked by privilegeChecker:', currentPath);
return false;
}
} catch (error) {
console.error('❌ PageManager: Error checking privilegeChecker:', error);
return false;
}
}
return true;
};
Promise.all([checkPrivilege(), checkPageAccess(pageData)]).then(([hasPrivilege, hasRBACAccess]) => {
const hasAccess = hasPrivilege && hasRBACAccess;
console.log('🔍 PageManager: Access check complete:', {
path: currentPath,
hasPrivilege,
hasRBACAccess,
hasAccess
});
if (!hasAccess) {
console.log('⛔ PageManager: Page not rendered due to access check:', currentPath);
return;
}
console.log('✅ PageManager: Rendering page:', {
path: currentPath,
name: pageData.name
});
setPageInstances(prev => {
console.log('📦 PageManager: Creating/updating page instance:', {
path: currentPath,
existingInstances: Array.from(prev.keys()),
willCreateNew: !prev.has(currentPath)
});
const newInstances = new Map(prev);
// Update active states
newInstances.forEach((instance) => {
instance.isActive = instance.path === currentPath;
});
// Create instance if it doesn't exist
if (!newInstances.has(currentPath)) {
console.log('📦 PageManager: Creating new page instance:', {
path: currentPath,
name: pageData.name
});
const shouldPreserve = pageData.preserveState || false;
const pageInstance: PageInstance = {
path: currentPath,
component: (
<div style={{ height: '100%', width: '100%' }}>
<Suspense fallback={<LoadingComponent />}>
{pageData.customComponent ? (
<pageData.customComponent />
) : (
<PageRenderer
pageData={pageData}
onButtonClick={(_buttonId, _button) => {
}}
/>
)}
</Suspense>
</div>
),
isActive: true,
shouldPreserve,
pageData
};
newInstances.set(currentPath, pageInstance);
console.log('✅ PageManager: Page instance created:', {
path: currentPath,
totalInstances: newInstances.size,
allPaths: Array.from(newInstances.keys())
});
} else {
console.log('🔄 PageManager: Page instance already exists, updating active state:', currentPath);
if (import.meta.env.DEV) {
const _instance = newInstances.get(currentPath);
void _instance; // Intentionally unused, for debugging purposes
}
}
return newInstances;
});
});
// Clean up non-preserved, inactive instances with delay for smooth transitions
const cleanupTimer = setTimeout(() => {
setPageInstances(currentInstances => {
const updatedInstances = new Map(currentInstances);
const instancesToDelete: string[] = [];
updatedInstances.forEach((instance, path) => {
if (!instance.isActive && !instance.shouldPreserve) {
instancesToDelete.push(path);
}
});
instancesToDelete.forEach(path => {
updatedInstances.delete(path);
});
return updatedInstances;
});
}, 500); // Wait for transition to complete before cleanup
return () => clearTimeout(cleanupTimer);
}, [currentPath]);
const pageData = getPageDataByPath(currentPath);
if (!pageData || pageData.hide || !pageData.moduleEnabled) {
return <ErrorComponent />;
}
// Animation variants for smooth transitions
const pageVariants = {
initial: {
opacity: 0,
scale: 1,
y: 0
},
in: {
opacity: 1,
scale: 1,
y: 0
},
out: {
opacity: 0,
scale: 1,
y: 0
}
};
const pageTransition = {
type: "tween" as const,
ease: "easeInOut" as const,
duration: 0.2
};
return (
<div style={{ height: '100%', width: '100%', position: 'relative' }}>
{Array.from(pageInstances.values()).map((instance) => {
const isVisible = instance.isActive;
if (instance.shouldPreserve) {
// Preserved pages: Always mounted, just show/hide with animations
return (
<motion.div
key={instance.path}
initial={false} // Don't animate initial mount for preserved pages
animate={{
opacity: isVisible ? 1 : 0,
}}
transition={pageTransition}
style={{
height: '100%',
width: '100%',
position: 'absolute',
top: 0,
left: 0,
zIndex: isVisible ? 1 : 0,
pointerEvents: isVisible ? 'auto' : 'none'
}}
>
{instance.component}
</motion.div>
);
} else if (isVisible) {
// Non-preserved pages: Use AnimatePresence for full mount/unmount
return (
<AnimatePresence key={instance.path} mode="wait">
<motion.div
key={instance.path}
style={{
height: '100%',
width: '100%',
position: 'absolute',
top: 0,
left: 0,
zIndex: 1
}}
initial="initial"
animate="in"
exit="out"
variants={pageVariants}
transition={pageTransition}
>
{instance.component}
</motion.div>
</AnimatePresence>
);
}
return null;
})}
</div>
);
};
export default PageManager;

File diff suppressed because it is too large Load diff

View file

@ -1,483 +0,0 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { allPageData, SidebarItem } from './data';
import { useLanguage } from '../../providers/language/LanguageContext';
import { resolveLanguageText, GenericPageData } from './pageInterface';
import { usePermissions } from '../../hooks/usePermissions';
import { FaHome, FaHatWizard, FaBriefcase, FaProjectDiagram } from 'react-icons/fa';
import { RiFolderSettingsFill } from 'react-icons/ri';
import { SidebarSubmenuItemData } from '../../components/Sidebar/sidebarTypes';
// 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
}
};
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);
}
// Try translation key (e.g., "start.real-estate.title")
const translationKey = `${fullPath}.title`;
const translated = t(translationKey);
if (translated !== translationKey) {
return translated;
}
// Try just the segment (e.g., "real-estate.title")
const segmentKey = `${pathSegment}.title`;
const segmentTranslated = t(segmentKey);
if (segmentTranslated !== segmentKey) {
return segmentTranslated;
}
// Fallback to capitalized segment
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 : 'Failed to load sidebar items');
} finally {
setLoading(false);
}
};
// Load sidebar items on mount and when language changes
useEffect(() => {
refreshSidebar();
}, [t]);
const contextValue: SidebarContextType = {
sidebarItems,
loading,
error,
refreshSidebar
};
return (
<SidebarContext.Provider value={contextValue}>
{children}
</SidebarContext.Provider>
);
};
export default SidebarProvider;

View file

@ -1,5 +0,0 @@
// Export page data and utilities
export * from './pages';
// Re-export the page interface
export * from '../pageInterface';

View file

@ -1,231 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaFileContract, FaPlus } from 'react-icons/fa';
import { getUserDataCache } from '../../../../../utils/userCache';
import { useMandates, useMandateOperations } from '../../../../../hooks/useAdminMandates';
import { isDateTimeType, type AttributeType } from '../../../../../utils/attributeTypeMapper';
import type { AttributeDefinition } from '../../../../../api/attributesApi';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: AttributeDefinition[]) => {
return attributes.map(attr => {
// Use attributeTypeMapper to check if this is a date/timestamp field - disable filtering for these
const isDateField = isDateTimeType(attr.type as AttributeType);
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,
// Disable filtering for date/timestamp fields
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
// Hook factory function for mandates data
const createMandatesHook = () => {
return () => {
const {
data: mandates,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchMandateById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = useMandates();
const {
handleMandateDelete,
handleMandateCreate,
handleMandateUpdate,
handleInlineUpdate,
deletingMandates,
editingMandates,
deleteError,
updateError
} = useMandateOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
// Handle single mandate deletion for FormGenerator
const handleDeleteSingle = useCallback(async (mandate: any) => {
const success = await handleMandateDelete(mandate.id);
if (success) {
refetch();
}
}, [handleMandateDelete, refetch]);
// Handle multiple mandate deletion for FormGenerator
const handleDeleteMultiple = useCallback(async (selectedMandates: any[]) => {
const mandateIds = selectedMandates.map(mandate => mandate.id);
const results = await Promise.all(
mandateIds.map(id => handleMandateDelete(id))
);
const allSuccessful = results.every((result: boolean) => result);
if (allSuccessful) {
refetch();
}
}, [handleMandateDelete, refetch]);
return {
data: mandates,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
// Operations
handleDelete: handleMandateDelete,
handleDeleteMultiple,
handleMandateUpdate,
handleInlineUpdate, // For inline boolean editing in table
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
// Loading states
deletingMandates,
editingMandates,
// Error states
deleteError,
updateError,
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns,
// Functions for EditActionButton
fetchMandateById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded,
// Entity type for EditActionButton
entityType: 'Mandate',
// Create operation for CreateButton
handleMandateCreate
};
};
};
export const mandatesPageData: GenericPageData = {
id: 'admin-mandates',
path: 'admin/mandates',
name: 'admin.mandates.title',
description: 'admin.mandates.description',
// Parent page
parentPath: 'admin',
showInSidebar: true,
// Visual
icon: FaFileContract,
title: 'admin.mandates.title',
subtitle: 'admin.mandates.subtitle',
// Header buttons
headerButtons: [
{
id: 'add-mandate',
label: 'admin.mandates.new_button',
variant: 'primary',
size: 'md',
icon: FaPlus,
formConfig: {
// Fields will be generated dynamically from attributes via generateCreateFieldsFromAttributes
// PageRenderer will use generateCreateFieldsFromAttributes if available, otherwise generateEditFieldsFromAttributes
fields: [], // Empty array - fields will be generated dynamically from attributes
popupTitle: 'admin.mandates.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleMandateCreate',
successMessage: 'admin.mandates.create.success',
errorMessage: 'admin.mandates.create.error'
}
}
],
// Content sections - using generic table approach
content: [
{
id: 'mandates-table',
type: 'table',
tableConfig: {
hookFactory: createMandatesHook,
// Columns are generated dynamically from attributes via hookData.columns
actionButtons: [
{
type: 'edit',
title: 'admin.mandates.action.edit',
idField: 'id',
nameField: 'id',
operationName: 'handleMandateUpdate',
loadingStateName: 'editingMandates',
fetchItemFunctionName: 'fetchMandateById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit mandates' };
}
},
{
type: 'delete',
title: 'admin.mandates.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingMandates',
// Only show if user has delete permission (permissions.delete !== 'n')
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 mandates' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'mandates-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
// Privilege checker: only allow sysadmin role
privilegeChecker: async () => {
const userData = getUserDataCache();
const roleLabels = Array.isArray(userData?.roleLabels) ? userData.roleLabels : [];
// Only allow access if user has "sysadmin" role
return roleLabels.includes('sysadmin');
},
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Mandates page activated');
},
onDeactivate: async () => {
if (import.meta.env.DEV) console.log('Mandates page deactivated');
}
};

View file

@ -1,243 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaUserShield, FaPlus } from 'react-icons/fa';
import { getUserDataCache } from '../../../../../utils/userCache';
import { useRbacRoles, useRbacRoleOperations } from '../../../../../hooks/useAdminRbacRoles';
import { isDateTimeType, type AttributeType } from '../../../../../utils/attributeTypeMapper';
import type { AttributeDefinition } from '../../../../../api/attributesApi';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: AttributeDefinition[]) => {
return attributes.map(attr => {
// Use attributeTypeMapper to check if this is a date/timestamp field - disable filtering for these
const isDateField = isDateTimeType(attr.type as AttributeType);
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,
// Disable filtering for date/timestamp fields
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
// Hook factory function for rbac roles data
const createRbacRolesHook = () => {
return () => {
const {
data: roles,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchRoleById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = useRbacRoles();
const {
handleRoleDelete,
handleRoleCreate,
handleRoleUpdate,
deletingRoles,
editingRoles,
deleteError,
updateError
} = useRbacRoleOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
// Handle single role deletion for FormGenerator
const handleDeleteSingle = useCallback(async (role: any) => {
const success = await handleRoleDelete(role.id);
if (success) {
refetch();
}
}, [handleRoleDelete, refetch]);
// Handle multiple role deletion for FormGenerator
const handleDeleteMultiple = useCallback(async (selectedRoles: any[]) => {
const roleIds = selectedRoles.map(role => role.id);
const results = await Promise.all(
roleIds.map(id => handleRoleDelete(id))
);
const allSuccessful = results.every((result: boolean) => result);
if (allSuccessful) {
refetch();
}
}, [handleRoleDelete, refetch]);
// Wrapper for create operation that refetches after success
const handleRoleCreateWithRefetch = useCallback(async (roleData: any) => {
const result = await handleRoleCreate(roleData);
if (result?.success) {
await refetch();
}
return result;
}, [handleRoleCreate, refetch]);
return {
data: roles,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
// Operations
handleDelete: handleRoleDelete,
handleDeleteMultiple,
handleRoleUpdate,
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
// Loading states
deletingRoles,
editingRoles,
// Error states
deleteError,
updateError,
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns,
// Functions for EditActionButton
fetchRoleById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded,
// Entity type for EditActionButton
entityType: 'Role',
// Create operation for CreateButton (with refetch)
handleRoleCreate: handleRoleCreateWithRefetch
};
};
};
export const rbacRolePageData: GenericPageData = {
id: 'admin-rbac-role',
path: 'admin/rbac-role',
name: 'admin.rbac-role.title',
description: 'admin.rbac-role.description',
// Parent page
parentPath: 'admin',
showInSidebar: true,
// Visual
icon: FaUserShield,
title: 'admin.rbac-role.title',
subtitle: 'admin.rbac-role.subtitle',
// Header buttons
headerButtons: [
{
id: 'add-role',
label: 'admin.rbac-role.new_button',
variant: 'primary',
size: 'md',
icon: FaPlus,
formConfig: {
// Fields will be generated dynamically from attributes via generateCreateFieldsFromAttributes
// PageRenderer will use generateCreateFieldsFromAttributes if available, otherwise generateEditFieldsFromAttributes
fields: [], // Empty array - fields will be generated dynamically from attributes
popupTitle: 'admin.rbac-role.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleRoleCreate',
successMessage: 'admin.rbac-role.create.success',
errorMessage: 'admin.rbac-role.create.error'
}
}
],
// Content sections - using generic table approach
content: [
{
id: 'rbac-role-description',
type: 'paragraph',
content: 'admin.rbac-role.description_text'
},
{
id: 'rbac-roles-table',
type: 'table',
tableConfig: {
hookFactory: createRbacRolesHook,
// Columns are generated dynamically from attributes via hookData.columns
actionButtons: [
{
type: 'edit',
title: 'admin.rbac-role.action.edit',
idField: 'id',
nameField: 'id',
operationName: 'handleRoleUpdate',
loadingStateName: 'editingRoles',
fetchItemFunctionName: 'fetchRoleById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit roles' };
}
},
{
type: 'delete',
title: 'admin.rbac-role.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingRoles',
// Only show if user has delete permission (permissions.delete !== 'n')
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 roles' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'rbac-roles-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
// Privilege checker: only allow sysadmin role
privilegeChecker: async () => {
const userData = getUserDataCache();
const roleLabels = Array.isArray(userData?.roleLabels) ? userData.roleLabels : [];
// Only allow access if user has "sysadmin" role
return roleLabels.includes('sysadmin');
},
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('RBAC Role page activated');
},
onDeactivate: async () => {
if (import.meta.env.DEV) console.log('RBAC Role page deactivated');
}
};

View file

@ -1,232 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaShieldAlt, FaPlus } from 'react-icons/fa';
import { getUserDataCache } from '../../../../../utils/userCache';
import { useRbacRules, useRbacRuleOperations } from '../../../../../hooks/useAdminRbacRules';
import { isDateTimeType, type AttributeType } from '../../../../../utils/attributeTypeMapper';
import type { AttributeDefinition } from '../../../../../api/attributesApi';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: AttributeDefinition[]) => {
return attributes.map(attr => {
// Use attributeTypeMapper to check if this is a date/timestamp field - disable filtering for these
const isDateField = isDateTimeType(attr.type as AttributeType);
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,
// Disable filtering for date/timestamp fields
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
// Hook factory function for RBAC rules data
const createRbacRulesHook = () => {
return () => {
const {
data: rbacRules,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchRbacRuleById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = useRbacRules();
const {
handleRbacRuleDelete,
handleRbacRuleCreate,
handleRbacRuleUpdate,
handleInlineUpdate,
deletingRbacRules,
editingRbacRules,
deleteError,
updateError
} = useRbacRuleOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
// Handle single RBAC rule deletion for FormGenerator
const handleDeleteSingle = useCallback(async (rule: any) => {
const success = await handleRbacRuleDelete(rule.id);
if (success) {
refetch();
}
}, [handleRbacRuleDelete, refetch]);
// Handle multiple RBAC rule deletion for FormGenerator
const handleDeleteMultiple = useCallback(async (selectedRules: any[]) => {
const ruleIds = selectedRules.map(rule => rule.id);
const results = await Promise.all(
ruleIds.map(id => handleRbacRuleDelete(id))
);
const allSuccessful = results.every((result: boolean) => result);
if (allSuccessful) {
refetch();
}
}, [handleRbacRuleDelete, refetch]);
return {
data: rbacRules,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
// Operations
handleDelete: handleRbacRuleDelete,
handleDeleteMultiple,
handleRbacRuleUpdate,
handleInlineUpdate, // For inline boolean editing in table
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
// Loading states
deletingRbacRules,
editingRbacRules,
// Error states
deleteError,
updateError,
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
columns: generatedColumns,
// Functions for EditActionButton
fetchRbacRuleById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded,
// Entity type for EditActionButton
entityType: 'AccessRule',
// Create operation for CreateButton
handleRbacRuleCreate,
// Pagination info for FormGeneratorTable
pagination
};
};
};
export const rbacRulesPageData: GenericPageData = {
id: 'admin-rbac-rules',
path: 'admin/rbac-rules',
name: 'admin.rbac-rules.title',
description: 'admin.rbac-rules.description',
// Parent page
parentPath: 'admin',
showInSidebar: true,
// Visual
icon: FaShieldAlt,
title: 'admin.rbac-rules.title',
subtitle: 'admin.rbac-rules.subtitle',
// Header buttons
headerButtons: [
{
id: 'add-rbac-rule',
label: 'admin.rbac-rules.new_button',
variant: 'primary',
size: 'md',
icon: FaPlus,
formConfig: {
// Fields will be generated dynamically from attributes via generateCreateFieldsFromAttributes
// PageRenderer will use generateCreateFieldsFromAttributes if available, otherwise generateEditFieldsFromAttributes
fields: [], // Empty array - fields will be generated dynamically from attributes
popupTitle: 'admin.rbac-rules.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleRbacRuleCreate',
successMessage: 'admin.rbac-rules.create.success',
errorMessage: 'admin.rbac-rules.create.error'
}
}
],
// Content sections - using generic table approach
content: [
{
id: 'rbac-rules-table',
type: 'table',
tableConfig: {
hookFactory: createRbacRulesHook,
// Columns are generated dynamically from attributes via hookData.columns
actionButtons: [
{
type: 'edit',
title: 'admin.rbac-rules.action.edit',
idField: 'id',
nameField: 'id',
operationName: 'handleRbacRuleUpdate',
loadingStateName: 'editingRbacRules',
fetchItemFunctionName: 'fetchRbacRuleById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit RBAC rules' };
}
},
{
type: 'delete',
title: 'admin.rbac-rules.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingRbacRules',
// Only show if user has delete permission (permissions.delete !== 'n')
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 RBAC rules' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'rbac-rules-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
// Privilege checker: only allow sysadmin role
privilegeChecker: async () => {
const userData = getUserDataCache();
const roleLabels = Array.isArray(userData?.roleLabels) ? userData.roleLabels : [];
// Only allow access if user has "sysadmin" role
return roleLabels.includes('sysadmin');
},
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('RBAC Rules page activated');
},
onDeactivate: async () => {
if (import.meta.env.DEV) console.log('RBAC Rules page deactivated');
}
};

View file

@ -1,267 +0,0 @@
import React, { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaUsers, FaPlus } from 'react-icons/fa';
import { IoMailOutline } from 'react-icons/io5';
import { useOrgUsers, useUserOperations } from '../../../../../hooks/useUsers';
import { getUserDataCache } from '../../../../../utils/userCache';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
// Check if this is a date/timestamp field - disable filtering for these
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,
// Disable filtering for date/timestamp fields
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
// Hook factory function for users data
const createUsersHook = () => {
return () => {
const {
data: users,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchUserById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = useOrgUsers();
const {
handleUserDelete,
handleUserCreate,
handleUserUpdate,
handleInlineUpdate,
handleSendPasswordLink,
deletingUsers,
editingUsers,
sendingPasswordLink,
creatingUser,
deleteError,
createError,
updateError,
passwordLinkError
} = useUserOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
// Wrapped create handler that adds mandateId from currentUser cache
const wrappedHandleUserCreate = useCallback(async (formData: any) => {
return await handleUserCreate(formData);
}, [handleUserCreate]);
// Handle single user deletion for FormGenerator
const handleDeleteSingle = useCallback(async (user: any) => {
const success = await handleUserDelete(user.id);
if (success) {
refetch();
}
}, [handleUserDelete, refetch]);
// Handle multiple user deletion for FormGenerator
const handleDeleteMultiple = useCallback(async (selectedUsers: any[]) => {
const userIds = selectedUsers.map(user => user.id);
const results = await Promise.all(
userIds.map(id => handleUserDelete(id))
);
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleUserDelete, refetch]);
return {
data: users,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
// Operations
handleDelete: handleUserDelete,
handleDeleteMultiple,
handleUserCreate: wrappedHandleUserCreate,
handleUserUpdate,
handleInlineUpdate, // For inline boolean editing in table
handleSendPasswordLink, // Send password setup link to user
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
// Loading states
deletingUsers,
editingUsers,
sendingPasswordLink,
creatingUser,
// Error states
deleteError,
createError,
updateError,
passwordLinkError,
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns,
// Functions for EditActionButton
fetchUserById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const teamMembersPageData: GenericPageData = {
id: 'admin-team-members',
path: 'admin/team-members',
name: 'team-members.title',
description: 'team-members.description',
// Parent page
parentPath: 'admin',
// Visual
icon: FaUsers,
title: 'team-members.title',
subtitle: 'team-members.subtitle',
// Header buttons
headerButtons: [
{
id: 'add-member',
label: 'team-members.new_button',
variant: 'primary',
size: 'md',
icon: FaPlus,
formConfig: {
// Fields will be generated dynamically from attributes via generateCreateFieldsFromAttributes
// PageRenderer will use generateCreateFieldsFromAttributes if available, otherwise generateEditFieldsFromAttributes
fields: [], // Empty array - fields will be generated dynamically from attributes
popupTitle: 'team-members.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleUserCreate',
successMessage: 'team-members.create.success',
errorMessage: 'team-members.create.error'
}
}
],
// Content sections - using generic table approach
content: [
{
id: 'users-table',
type: 'table',
tableConfig: {
hookFactory: createUsersHook,
// Columns are generated dynamically from attributes via hookData.columns
// Standard action buttons (built-in: edit, delete, view, copy)
actionButtons: [
{
type: 'edit',
title: 'team-members.action.edit',
idField: 'id',
nameField: 'username',
operationName: 'handleUserUpdate',
loadingStateName: 'editingUsers',
fetchItemFunctionName: 'fetchUserById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit users' };
}
},
{
type: 'delete',
title: 'team-members.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingUsers',
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 users' };
}
}
],
// Custom action buttons (entity-specific)
customActions: [
{
id: 'sendPasswordLink',
icon: React.createElement(IoMailOutline),
title: 'team-members.action.sendPasswordLink',
onClick: async (row: any, hookData: any) => {
if (hookData?.handleSendPasswordLink) {
await hookData.handleSendPasswordLink(row.id);
}
},
// Only show for users with local authentication (not msft/google)
visible: (row: any) => row.authenticationAuthority === 'local',
disabled: (_row: any, hookData: any) => {
if (!hookData?.permissions) return { disabled: false, message: '' };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to send password link' };
},
loading: (row: any, hookData: any) => hookData?.sendingPasswordLink?.has(row.id) || false
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'users-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true, // Keep page mounted and prevent refetching
moduleEnabled: true,
// Privilege checker: only allow sysadmin role
privilegeChecker: async () => {
const userData = getUserDataCache();
const roleLabels = Array.isArray(userData?.roleLabels) ? userData.roleLabels : [];
// Only allow access if user has "sysadmin" role
return roleLabels.includes('sysadmin');
},
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Team Members activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Team Members loaded - can initialize users list here');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Team Members unloaded - cleanup users references');
}
};

View file

@ -1,282 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../pageInterface';
import { FaCog, FaPlus } from 'react-icons/fa';
import { useAutomations, useAutomationOperations } from '../../../../hooks/useAutomations';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes
.filter(attr => {
// Exclude template, placeholders and complex fields from table display
const attrNameLower = attr.name.toLowerCase();
const excludedColumns = ['template', 'placeholders', 'executionlogs', 'execution_logs'];
return !excludedColumns.includes(attrNameLower);
})
.map(attr => {
const attrNameLower = attr.name.toLowerCase();
const isDateField = attr.type === 'date' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
const column: any = {
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
};
// Format schedule field
if (attrNameLower === 'schedule') {
column.formatter = (value: any) => {
if (!value) return '-';
const scheduleLabels: Record<string, string> = {
'0 */4 * * *': 'Every 4 hours',
'0 22 * * *': 'Daily at 22:00',
'0 10 * * 1': 'Weekly Monday 10:00'
};
return scheduleLabels[value] || value;
};
}
// Format active field as badge
if (attrNameLower === 'active') {
column.type = 'boolean';
}
// Format placeholders as count
if (attrNameLower === 'placeholders') {
column.formatter = (value: any) => {
if (!value) return '-';
let obj;
if (typeof value === 'string') {
try {
obj = JSON.parse(value);
} catch {
return '-';
}
} else if (typeof value === 'object') {
obj = value;
} else {
return '-';
}
const count = Object.keys(obj).length;
return `${count} placeholder${count !== 1 ? 's' : ''}`;
};
}
return column;
});
};
// Hook factory function for automations data
const createAutomationsHook = () => {
return () => {
const {
automations,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchAutomationById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = useAutomations();
const {
handleAutomationDelete,
handleAutomationCreate,
handleAutomationUpdate,
handleAutomationExecute,
handleAutomationToggleActive,
deletingAutomations,
creatingAutomation,
executingAutomations,
deleteError,
createError,
updateError
} = useAutomationOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
// Handle single automation deletion
const handleDeleteSingle = useCallback(async (automation: any) => {
const success = await handleAutomationDelete(automation.id);
if (success) {
refetch();
}
}, [handleAutomationDelete, refetch]);
// Handle multiple automation deletion
const handleDeleteMultiple = useCallback(async (selectedAutomations: any[]) => {
const results = await Promise.all(
selectedAutomations.map(a => handleAutomationDelete(a.id))
);
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleAutomationDelete, refetch]);
// Wrapped create handler
const wrappedHandleAutomationCreate = useCallback(async (formData: any) => {
return await handleAutomationCreate(formData);
}, [handleAutomationCreate]);
return {
data: automations,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
// Operations
handleDelete: handleAutomationDelete,
handleDeleteMultiple,
handleAutomationCreate: wrappedHandleAutomationCreate,
handleAutomationUpdate,
handleAutomationExecute,
handleAutomationToggleActive,
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
// Loading states
deletingAutomations,
creatingAutomation,
executingAutomations,
// Error states
deleteError,
createError,
updateError,
// Attributes and permissions
attributes,
permissions,
pagination,
columns: generatedColumns,
// Functions for EditActionButton
fetchAutomationById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const automationsPageData: GenericPageData = {
id: 'workflows-automations',
path: 'workflows/automations',
name: 'automations.title',
description: 'automations.description',
// Parent page - under 'workflows' group
parentPath: 'workflows',
// Visual
icon: FaCog,
title: 'automations.title',
subtitle: 'automations.subtitle',
// Header buttons
headerButtons: [
{
id: 'new-automation',
label: 'automations.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [], // Fields will be generated dynamically from attributes
popupTitle: 'automations.modal.create.title',
popupSize: 'large',
createOperationName: 'handleAutomationCreate',
successMessage: 'automations.create.success',
errorMessage: 'automations.create.error'
}
}
],
// Content sections - using generic table approach
content: [
{
id: 'automations-table',
type: 'table',
tableConfig: {
hookFactory: createAutomationsHook,
// Columns are generated dynamically from attributes via hookData.columns
actionButtons: [
{
type: 'play',
title: 'automations.action.execute',
idField: 'id',
operationName: 'handleAutomationExecute',
loadingStateName: 'executingAutomations',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasExecute = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasExecute, message: 'No permission to execute automations' };
}
},
{
type: 'edit',
title: 'automations.action.edit',
idField: 'id',
nameField: 'label',
operationName: 'handleAutomationUpdate',
loadingStateName: 'updatingAutomations',
fetchItemFunctionName: 'fetchAutomationById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit automations' };
}
},
{
type: 'delete',
title: 'automations.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingAutomations',
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 automations' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'automations-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Automations activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Automations loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Automations unloaded');
}
};

View file

@ -1,79 +0,0 @@
import { GenericPageData } from '../../pageInterface';
import { LuMessageSquare } from 'react-icons/lu';
import { IoMdSend } from 'react-icons/io';
import { MdStop } from 'react-icons/md';
import { createChatbotHook } from '../../../../hooks/useChatbot';
export const chatbotPageData: GenericPageData = {
id: 'start-chatbot',
path: 'start/chatbot',
name: 'Chatbot',
description: 'Simple chatbot interface for conversations',
// Parent page
parentPath: 'start',
// Visual
icon: LuMessageSquare,
title: 'Chatbot',
subtitle: 'Chat with AI assistant',
// No header buttons (simpler than dashboard)
headerButtons: [],
// Content sections
content: [
{
id: 'chatbot-history',
type: 'chatHistory',
chatHistoryConfig: {
emptyMessage: 'No chat history yet. Start a conversation to see it here.'
}
},
{
id: 'chatbot-messages',
type: 'messages',
messagesConfig: {
variant: 'chat',
showDocuments: true,
showMetadata: false,
showProgress: false,
emptyMessage: 'No messages yet. Start a conversation to see messages here.'
}
},
{
id: 'chatbot-input',
type: 'inputForm',
inputFormConfig: {
hookFactory: createChatbotHook,
placeholder: 'Type your message here...',
buttonLabel: 'Send',
stopButtonLabel: 'Stop',
buttonIcon: IoMdSend,
stopButtonIcon: MdStop,
buttonVariant: 'primary',
stopButtonVariant: 'danger',
buttonSize: 'md',
textFieldSize: 'md',
showFileUpload: false
}
}
],
// Page behavior
persistent: true,
preserveState: true,
preload: true,
moduleEnabled: true,
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Chatbot activated - state preserved');
},
onDeactivate: async () => {
if (import.meta.env.DEV) console.log('Chatbot deactivated - keeping state');
}
};

View file

@ -1,295 +0,0 @@
import React, { useCallback } from 'react';
import { GenericPageData } from '../../pageInterface';
import { FaGoogle, FaMicrosoft, FaLink, FaSync, FaPlug } from 'react-icons/fa';
import { useConnections } from '../../../../hooks/useConnections';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => ({
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: attr.filterable !== false,
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
}));
};
// Hook factory function for connections data
const createConnectionsHook = () => {
return () => {
const {
connections,
fetchConnections,
deleteConnection,
createGoogleConnectionAndAuth,
createMicrosoftConnectionAndAuth,
connectWithPopup,
refreshMicrosoftToken,
refreshGoogleToken,
isConnecting,
isLoading,
error,
attributes,
permissions,
pagination
} = useConnections();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
// Refetch function for pagination-aware refresh
const refetch = useCallback(async (params?: any) => {
await fetchConnections(params);
}, [fetchConnections]);
// Handle connection deletion
const handleDelete = useCallback(async (connectionId: string) => {
try {
await deleteConnection(connectionId);
// Refresh connections after deletion - FormGenerator will handle pagination
// by calling refetch with current pagination params via its useEffect
return true;
} catch (error) {
console.error('Failed to delete connection:', error);
return false;
}
}, [deleteConnection]);
// Handle single connection deletion for FormGenerator
const handleDeleteSingle = useCallback(async (connection: any) => {
const success = await handleDelete(connection.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
// Handle multiple connection deletion for FormGenerator
const handleDeleteMultiple = useCallback(async (selectedConnections: any[]) => {
const connectionIds = selectedConnections.map(conn => conn.id);
const results = await Promise.all(
connectionIds.map(id => handleDelete(id))
);
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: connections,
loading: isLoading,
error: error,
refetch,
// Operations
handleDelete,
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
connectWithPopup,
createGoogleConnectionAndAuth,
createMicrosoftConnectionAndAuth,
// Token refresh operations
refreshMicrosoftToken,
refreshGoogleToken,
// Loading states
isConnecting,
deletingConnections: new Set(), // Placeholder for consistency with other pages
refreshingConnections: new Set<string>(), // Track which connections are refreshing
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
columns: generatedColumns, // Return generated columns
pagination
};
};
};
export const connectionsPageData: GenericPageData = {
id: 'basedata-connections',
path: 'basedata/connections',
name: 'connections.title',
description: 'connections.title',
// Parent page
parentPath: 'basedata',
// Visual
icon: FaLink,
title: 'connections.title',
subtitle: 'connections.subtitle',
// Header buttons - Create Google and Microsoft connections
headerButtons: [
{
id: 'add-google-connection',
label: 'connections.add_google_button',
icon: FaGoogle,
variant: 'primary',
onClick: async (hookData: any) => {
if (!hookData) {
console.error('No hookData available for Google connection creation');
return;
}
if (!hookData.createGoogleConnectionAndAuth) {
console.error('createGoogleConnectionAndAuth function not found in hookData', hookData);
return;
}
try {
await hookData.createGoogleConnectionAndAuth();
// Refresh connections after creation
if (hookData?.refetch) {
await hookData.refetch();
}
} catch (error) {
console.error('Failed to create Google connection:', error);
}
},
// Only show if user has create permission
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasCreate = hookData.permissions.create !== 'n' && hookData.permissions.view;
return { disabled: !hasCreate, message: 'No permission to create connections' };
}
},
{
id: 'add-microsoft-connection',
label: 'connections.add_microsoft_button',
icon: FaMicrosoft,
variant: 'primary',
onClick: async (hookData: any) => {
if (!hookData) {
console.error('No hookData available for Microsoft connection creation');
return;
}
if (!hookData.createMicrosoftConnectionAndAuth) {
console.error('createMicrosoftConnectionAndAuth function not found in hookData', hookData);
return;
}
try {
await hookData.createMicrosoftConnectionAndAuth();
// Refresh connections after creation
if (hookData?.refetch) {
await hookData.refetch();
}
} catch (error) {
console.error('Failed to create Microsoft connection:', error);
}
},
// Only show if user has create permission
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasCreate = hookData.permissions.create !== 'n' && hookData.permissions.view;
return { disabled: !hasCreate, message: 'No permission to create connections' };
}
}
],
// Content sections - using generic table approach
content: [
{
id: 'connections-table',
type: 'table',
tableConfig: {
hookFactory: createConnectionsHook,
// Columns are generated dynamically from attributes via hookData.columns
// Standard action buttons
actionButtons: [
{
type: 'delete',
title: 'connections.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingConnections',
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 connections' };
}
}
],
// Custom action buttons (entity-specific)
customActions: [
{
id: 'connect',
icon: React.createElement(FaPlug),
title: 'connections.action.connect',
onClick: async (row: any, hookData: any) => {
if (hookData?.connectWithPopup) {
await hookData.connectWithPopup(row.id);
}
},
// Only show connect button if status is not 'active'
visible: (row: any) => row.status !== 'active',
disabled: (_row: any, hookData: any) => {
if (!hookData?.permissions) return { disabled: false, message: '' };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to connect' };
},
loading: (_row: any, hookData: any) => hookData?.isConnecting || false
},
{
id: 'refresh',
icon: React.createElement(FaSync),
title: 'connections.action.refresh',
onClick: async (row: any, hookData: any) => {
// Determine which refresh function to use based on authority
if (row.authority === 'msft' && hookData?.refreshMicrosoftToken) {
await hookData.refreshMicrosoftToken(row.id);
if (hookData?.refetch) await hookData.refetch();
} else if (row.authority === 'google' && hookData?.refreshGoogleToken) {
await hookData.refreshGoogleToken(row.id);
if (hookData?.refetch) await hookData.refetch();
}
},
// Only show refresh button if status is 'active' (already connected)
visible: (row: any) => row.status === 'active',
disabled: (_row: any, hookData: any) => {
if (!hookData?.permissions) return { disabled: false, message: '' };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to refresh token' };
},
loading: (row: any, hookData: any) => hookData?.refreshingConnections?.has(row.id) || false
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'connections-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true, // Keep page mounted and prevent refetching
moduleEnabled: true,
// Sidebar - will be shown as subpage under Administration
// No drag and drop for connections
dragDropConfig: {
enabled: false
},
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Connections activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Connections loaded - can initialize connections list here');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Connections unloaded - cleanup connections references');
}
};

View file

@ -1,97 +0,0 @@
import { GenericPageData } from '../../pageInterface';
import { LuTicket } from 'react-icons/lu';
import { IoMdSend } from 'react-icons/io';
import { MdStop } from 'react-icons/md';
import { HiOutlineCollection } from 'react-icons/hi';
import { createDashboardHook } from '../../../../hooks/usePlayground';
export const dashboardPageData: GenericPageData = {
id: 'workflows-playground',
path: 'workflows/playground',
name: 'chatPlayground.title',
description: 'chatPlayground.description',
// Parent page - now under 'workflows' group
parentPath: 'workflows',
// Visual
icon: LuTicket,
title: 'chatPlayground.title',
subtitle: 'chatPlayground.subtitle',
// Header buttons
headerButtons: [
{
id: 'workflow-selector',
label: 'dashboard.workflow.selector',
variant: 'primary',
size: 'md',
icon: HiOutlineCollection,
dropdownConfig: {
type: 'dropdown',
items: [], // Will be populated from hookData
placeholder: 'dashboard.workflow.select',
emptyMessage: 'dashboard.workflow.empty',
headerText: 'dashboard.workflow.header',
onSelect: () => {}, // Placeholder - actual handler comes from dataSource.onSelectMethod
dataSource: {
itemsProperty: 'workflowItems',
selectedIdProperty: 'selectedWorkflowId',
onSelectMethod: 'onWorkflowSelect'
}
}
}
],
// Content sections
content: [
{
id: 'workflow-messages',
type: 'messages',
messagesConfig: {
showDocuments: true,
showMetadata: false,
showProgress: false,
emptyMessage: 'No messages yet. Start a workflow to see messages here.'
}
},
{
id: 'workflow-log',
type: 'log',
logConfig: {
emptyMessage: 'No log information available'
}
},
{
id: 'workflow-input',
type: 'inputForm',
inputFormConfig: {
hookFactory: createDashboardHook,
placeholder: 'dashboard.input.placeholder',
buttonLabel: 'dashboard.button.send',
stopButtonLabel: 'dashboard.button.stop',
buttonIcon: IoMdSend,
stopButtonIcon: MdStop,
buttonVariant: 'primary',
stopButtonVariant: 'danger',
buttonSize: 'md',
textFieldSize: 'md'
}
}
],
// Page behavior
persistent: true,
preserveState: true,
preload: true,
moduleEnabled: true,
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Dashboard activated - state preserved');
},
onDeactivate: async () => {
if (import.meta.env.DEV) console.log('Dashboard deactivated - keeping state');
}
};

View file

@ -1,302 +0,0 @@
import React, { useCallback } from 'react';
import { GenericPageData } from '../../pageInterface';
import { FaRegFileAlt, FaUpload, FaDownload } from 'react-icons/fa';
import { useUserFiles, useFileOperations } from '../../../../hooks/useFiles';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
// Check if this is a date/timestamp field - disable filtering for these
const isDateField = attr.type === 'date' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
const column: any = {
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,
// Disable filtering for date/timestamp fields
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
// Note: fileHash formatting is now handled automatically by FormGeneratorTable
// with copyable truncated values - no custom formatter needed
// Add formatter for fileSize to display human-readable sizes
if (attr.name === 'fileSize' || attr.name === 'file_size' || attr.name === 'size') {
column.formatter = (value: any) => {
if (value === null || value === undefined) return '-';
const bytes = typeof value === 'number' ? value : parseFloat(value);
if (isNaN(bytes) || bytes < 0) return value || '-';
// Format bytes to human-readable format
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
// Round to 2 decimal places, but show integers if possible
const formattedSize = size % 1 === 0 ? size.toString() : size.toFixed(2);
return `${formattedSize} ${units[unitIndex]}`;
};
}
return column;
});
};
// Hook factory function for files data
const createFilesHook = () => {
return () => {
const {
data: files,
loading,
error,
refetch,
removeFileOptimistically,
updateFileOptimistically,
attributes,
permissions,
pagination,
fetchFileById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
} = useUserFiles();
const {
handleFileDownload,
handleFileDeleteMultiple,
handleFilePreview,
handleFileUpdate,
handleFileDelete,
handleFileUpload,
deletingFiles,
previewingFiles,
editingFiles,
downloadingFiles,
uploadingFile
} = useFileOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
// Handle single file deletion for FormGenerator
const handleDeleteSingle = useCallback(async (file: any) => {
const success = await handleFileDelete(file.id);
if (success) {
refetch();
}
}, [handleFileDelete, refetch]);
// Handle multiple file deletion for FormGenerator
const handleDeleteMultiple = useCallback(async (selectedFiles: any[]) => {
const fileIds = selectedFiles.map(file => file.id);
const success = await handleFileDeleteMultiple(fileIds);
if (success) {
refetch();
}
}, [handleFileDeleteMultiple, refetch]);
// Wrap handleFileUpload for upload button and drag-and-drop
// GenericDataHook expects: (file: File) => Promise<{ success: boolean; data: any }>
const handleUpload = useCallback(async (file: File): Promise<{ success: boolean; data: any }> => {
const result = await handleFileUpload(file);
if (result.success) {
// Refetch files list after successful upload
refetch();
}
// Map fileData to data to match GenericDataHook interface
return {
success: result.success,
data: 'fileData' in result ? result.fileData : ('data' in result ? result.data : null)
};
}, [handleFileUpload, refetch]);
return {
data: files,
loading,
error,
refetch,
removeFileOptimistically, // Expose optimistic removal (generic name for DeleteActionButton)
updateFileOptimistically, // Expose optimistic update for EditActionButton
// Operations
handleDownload: handleFileDownload,
handleDelete: handleFileDelete,
handleDeleteMultiple,
handlePreview: handleFilePreview,
handleFileUpdate,
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
// Loading states
downloadingFiles,
deletingFiles,
previewingFiles,
editingFiles,
uploadingFile,
// Upload functionality for button and drag-and-drop
handleUpload,
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns, // Return generated columns
// Functions for EditActionButton
fetchFileById, // Fetch single file by ID
generateEditFieldsFromAttributes, // Generate edit fields from attributes
ensureAttributesLoaded // Generic function to ensure attributes are loaded
};
};
};
export const filesPageData: GenericPageData = {
id: 'basedata-files',
path: 'basedata/files',
name: 'files.title',
description: 'files.description',
// Parent page - now under 'basedata' group (formerly 'administration')
parentPath: 'basedata',
// Visual
icon: FaRegFileAlt,
title: 'files.title',
subtitle: 'files.title',
// Header buttons
headerButtons: [
{
id: 'upload-file',
label: 'files.upload_button',
icon: FaUpload,
variant: 'primary',
// onClick will be handled by PageRenderer to render UploadButton
onClick: () => {}, // Placeholder - PageRenderer will detect this as upload button
// Only show if user has create permission (permissions.create !== 'n')
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasCreate = hookData.permissions.create !== 'n' && hookData.permissions.view;
return { disabled: !hasCreate, message: 'No permission to upload files' };
}
}
],
// Content sections - using generic table approach
content: [
{
id: 'files-table',
type: 'table',
tableConfig: {
hookFactory: createFilesHook,
// Columns are generated dynamically from attributes via hookData.columns
// Standard action buttons (built-in: edit, delete, view, copy)
actionButtons: [
{
type: 'view',
title: 'files.action.preview',
idField: 'id',
nameField: 'fileName',
typeField: 'mimeType',
operationName: 'handlePreview',
loadingStateName: 'previewingFiles',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasRead = hookData.permissions.read !== 'n' && hookData.permissions.view;
return { disabled: !hasRead, message: 'No permission to preview files' };
}
},
{
type: 'edit',
title: 'files.action.edit',
idField: 'id',
nameField: 'fileName',
operationName: 'handleFileUpdate',
loadingStateName: 'editingFiles',
fetchItemFunctionName: 'fetchFileById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit files' };
}
},
{
type: 'delete',
title: 'files.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingFiles',
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 files' };
}
}
],
// Custom action buttons (entity-specific)
customActions: [
{
id: 'download',
icon: React.createElement(FaDownload),
title: 'files.action.download',
onClick: async (row: any, hookData: any) => {
if (hookData?.handleDownload) {
await hookData.handleDownload(row.id, row.fileName, row.mimeType);
}
},
disabled: (_row: any, hookData: any) => {
if (!hookData?.permissions) return { disabled: false, message: '' };
const hasRead = hookData.permissions.read !== 'n' && hookData.permissions.view;
return { disabled: !hasRead, message: 'No permission to download files' };
},
loading: (row: any, hookData: any) => hookData?.downloadingFiles?.has(row.id) || false
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'files-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true, // Keep page mounted and prevent refetching
moduleEnabled: true,
// Sidebar - will be shown as subpage under Administration
// Drag and drop configuration
dragDropConfig: {
enabled: true,
accept: '*/*', // Accept all file types
multiple: true, // Allow multiple files
// overlayText and overlaySubtext will use default translations from DragDropOverlay
},
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Files activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Files loaded - can initialize file lists here');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Files unloaded - cleanup file references');
}
};

View file

@ -1,84 +0,0 @@
// Export all page data
export { dashboardPageData } from './dashboard';
export { filesPageData } from './files';
export { workflowsPageData } from './workflows';
export { automationsPageData } from './automations';
export { connectionsPageData } from './connections';
export { teamMembersPageData } from './admin/team-members';
export { promptsPageData } from './prompts';
export { settingsPageData } from './settings';
export { pekPageData } from './pek';
export { pekTablesPageData } from './pek-tables';
export { chatbotPageData } from './chatbot';
export { mandatesPageData } from './admin/mandates';
export { rbacRulesPageData } from './admin/rbac-rules';
export { rbacRolePageData } from './admin/rbac-role';
// Trustee pages (no container - SidebarProvider creates virtual parent group)
export {
trusteeOrganisationsPageData,
trusteeRolesPageData,
trusteeAccessPageData,
trusteeContractsPageData,
trusteeDocumentsPageData,
trusteePositionsPageData,
trusteePages
} from './trustee';
// Import all page data
import { dashboardPageData } from './dashboard';
import { filesPageData } from './files';
import { workflowsPageData } from './workflows';
import { automationsPageData } from './automations';
import { connectionsPageData } from './connections';
import { teamMembersPageData } from './admin/team-members';
import { promptsPageData } from './prompts';
import { settingsPageData } from './settings';
import { pekPageData } from './pek';
import { pekTablesPageData } from './pek-tables';
import { chatbotPageData } from './chatbot';
import { mandatesPageData } from './admin/mandates';
import { rbacRulesPageData } from './admin/rbac-rules';
import { rbacRolePageData } from './admin/rbac-role';
import { trusteePages } from './trustee';
// Array of all page data
export const allPageData = [
// Workflows group
dashboardPageData, // Chat Playground
workflowsPageData, // Workflows list
automationsPageData, // Automations
// Basedata group
filesPageData,
promptsPageData,
// Other pages
connectionsPageData,
speechPageData,
settingsPageData,
pekPageData,
pekTablesPageData,
chatbotPageData,
// Trustee pages
...trusteePages,
// Admin pages
teamMembersPageData,
mandatesPageData,
rbacRulesPageData,
rbacRolePageData,
];
// Helper function to get page data by path
export const getPageDataByPath = (path: string) => {
return allPageData.find(page => page.path === path);
};
// Helper function to get all pages with subpages organized
export const getPageHierarchy = () => {
const pages = allPageData.filter(page => !page.parentPath);
const subpages = allPageData.filter(page => page.parentPath);
return {
mainPages: pages,
subpages: subpages,
allPages: allPageData
};
};

View file

@ -1,208 +0,0 @@
import { GenericPageData } from '../../pageInterface';
import { FaTable, FaPlus } from 'react-icons/fa';
import { createProjectsTableHook, createParzellenTableHook } from '../../../../hooks/usePekTables';
import { getUserDataCache } from '../../../../utils/userCache';
export const pekTablesPageData: GenericPageData = {
id: 'pek-tables',
path: 'start/real-estate/pek-tables',
name: 'Projektmanagement',
description: 'Projektmanagement mit Tabellen',
// Parent page
parentPath: 'start.real-estate',
// Visual
icon: FaTable,
title: 'Projektmanagement',
subtitle: 'Datenverwaltung',
// Header buttons
headerButtons: [
{
id: 'create-project',
label: 'Neues Projekt',
variant: 'primary',
size: 'lg',
icon: FaPlus,
formConfig: {
fields: [], // Will be generated from attributes via generateEditFieldsFromAttributes
popupTitle: 'Neues Projekt erstellen',
popupSize: 'large',
createOperationName: 'handleProjectCreate',
multiStep: true // Enable multi-step form with Step 1 (label) and Step 2 (parcel selection)
},
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasCreate = hookData.permissions.create !== 'n' && hookData.permissions.view;
return { disabled: !hasCreate, message: 'No permission to create projects' };
}
}
],
// Content sections
content: [
{
id: 'projektmanagement-layout',
type: 'columns',
columnsConfig: {
columns: [
{
id: 'main-column',
width: '3fr',
content: [
{
id: 'tables-tabs',
type: 'tabs',
tabsConfig: {
tabs: [
{
id: 'projects',
label: 'Projekte',
content: [
{
id: 'projects-table',
type: 'table',
tableConfig: {
hookFactory: createProjectsTableHook,
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
emptyMessage: 'Noch keine Projekte erstellt, erstelle jetzt dein erstes Projekt!',
actionButtons: [
{
type: 'edit',
title: 'common.edit',
idField: 'id',
operationName: 'handleProjectUpdate',
loadingStateName: 'editingProjects',
fetchItemFunctionName: 'fetchProjectById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit projects' };
}
},
{
type: 'delete',
title: 'common.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingProjects',
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 projects' };
}
}
]
}
}
]
},
{
id: 'parzellen',
label: 'Parzellen',
content: [
{
id: 'parzellen-table',
type: 'table',
tableConfig: {
hookFactory: createParzellenTableHook,
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
emptyMessage: 'Noch keine Parzellen erstellt, erstelle jetzt dein erstes Projekt und füge eine Parzelle hinzu!',
actionButtons: [
{
type: 'view',
title: 'common.view',
idField: 'id',
nameField: 'label',
operationName: 'handleParzelleView',
loadingStateName: 'viewingParzellen',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasRead = hookData.permissions.read !== 'n' && hookData.permissions.view;
return { disabled: !hasRead, message: 'No permission to view parzellen' };
}
},
{
type: 'edit',
title: 'common.edit',
idField: 'id',
operationName: 'handleParzelleUpdate',
loadingStateName: 'editingParzellen',
fetchItemFunctionName: 'fetchParzelleById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit parzellen' };
}
},
{
type: 'delete',
title: 'common.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingParzellen',
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 parzellen' };
}
}
]
}
}
]
}
],
defaultTabId: 'projects'
}
}
]
},
{
id: 'sidebar-column',
width: '1fr',
content: []
}
],
gap: '1rem'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
// Sidebar
order: 11,
// Privilege checker: deny access for "user" role
privilegeChecker: async () => {
const userData = getUserDataCache();
const roleLabels = Array.isArray(userData?.roleLabels) ? userData.roleLabels : [];
// Deny access if user has "user" role
return !roleLabels.includes('user');
},
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('PEK Tables page activated');
},
onDeactivate: async () => {
if (import.meta.env.DEV) console.log('PEK Tables page deactivated');
}
};

View file

@ -1,124 +0,0 @@
import { GenericPageData } from '../../pageInterface';
import { FaBuilding } from 'react-icons/fa';
import { IoMdSend } from 'react-icons/io';
import PekLocationInput from './pek/PekLocationInput';
import PekMapView from './pek/PekMapView';
import { usePek } from '../../../../hooks/usePek';
import PekPageWrapper from './pek/PekPageWrapper';
import { getUserDataCache } from '../../../../utils/userCache';
// Hook factory for PEK page
const createPekHook = () => {
return () => {
const pekData = usePek();
const handleSubmit = async () => {
await pekData.processCommand(pekData.commandInput);
};
return {
// Messages for command results
messages: pekData.commandResults,
// Loading states
loading: pekData.isProcessingCommand || pekData.isSearchingParcel,
error: pekData.commandError || pekData.parcelSearchError || pekData.locationError,
// Empty data array for compatibility
data: [],
// Input form properties (for command input)
inputValue: pekData.commandInput,
onInputChange: pekData.setCommandInput,
handleSubmit,
isSubmitting: pekData.isProcessingCommand
};
};
};
export const pekPageData: GenericPageData = {
id: 'pek',
path: 'start/real-estate/pek',
name: 'projects.title',
description: 'projects.description',
// Parent page
parentPath: 'start.real-estate',
// Visual
icon: FaBuilding,
title: 'projects.title',
subtitle: 'projects.subtitle',
// Header buttons
headerButtons: [],
// Content sections
content: [
{
id: 'pek-description',
type: 'paragraph',
content: 'projects.description_text'
},
{
id: 'pek-location-input',
type: 'custom',
customComponent: PekLocationInput
},
{
id: 'pek-map-view',
type: 'custom',
customComponent: PekMapView
},
{
id: 'pek-command-input',
type: 'inputForm',
inputFormConfig: {
hookFactory: createPekHook,
placeholder: 'projects.command.placeholder',
buttonLabel: 'Senden',
buttonIcon: IoMdSend,
buttonVariant: 'primary',
buttonSize: 'md',
textFieldSize: 'md'
}
},
{
id: 'pek-command-results',
type: 'messages',
messagesConfig: {
variant: 'chat',
showDocuments: false,
showMetadata: false,
showProgress: false,
emptyMessage: 'projects.command.empty'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
// Sidebar
order: 10,
// Privilege checker: deny access for "user" role
privilegeChecker: async () => {
const userData = getUserDataCache();
const roleLabels = Array.isArray(userData?.roleLabels) ? userData.roleLabels : [];
// Deny access if user has "user" role
return !roleLabels.includes('user');
},
// Custom component wrapper with PekProvider
customComponent: PekPageWrapper,
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('PEK page activated');
},
onDeactivate: async () => {
if (import.meta.env.DEV) console.log('PEK page deactivated');
}
};

View file

@ -1,64 +0,0 @@
.locationInputContainer {
width: 100%;
margin-bottom: 1.5rem;
}
.fieldsRow {
display: flex;
gap: 1rem;
align-items: flex-end;
}
.fieldWrapper {
flex: 1;
}
.buttonsWrapper {
display: flex;
flex-direction: row;
gap: 0.5rem;
min-width: 150px;
}
.searchButton {
white-space: nowrap;
}
.locationButton {
white-space: nowrap;
}
@media (max-width: 1024px) {
.fieldsRow {
flex-wrap: wrap;
}
.buttonsWrapper {
width: 100%;
}
.fieldWrapper {
min-width: calc(50% - 0.5rem);
}
}
@media (max-width: 768px) {
.fieldsRow {
flex-direction: column;
}
.fieldWrapper {
width: 100%;
min-width: 100%;
}
.buttonsWrapper {
width: 100%;
}
.searchButton,
.locationButton {
flex: 1;
}
}

View file

@ -1,87 +0,0 @@
import React from 'react';
import { TextField, Button } from '../../../../../components/UiComponents';
import { FaLocationArrow } from 'react-icons/fa';
import { IoMdSend } from 'react-icons/io';
import { usePekContext } from '../../../../../contexts/PekContext';
import styles from './PekLocationInput.module.css';
const PekLocationInput: React.FC = () => {
const {
kanton: _kanton,
setKanton: _setKanton,
gemeinde: _gemeinde,
setGemeinde: _setGemeinde,
adresse,
setAdresse,
buildLocationString,
useCurrentLocation,
isGettingLocation,
locationError: _locationError,
searchParcel,
isSearchingParcel
} = usePekContext();
const handleSearch = async () => {
const locationString = buildLocationString();
if (locationString.trim()) {
await searchParcel(locationString.trim(), true);
}
};
const handleUseCurrentLocation = async () => {
await useCurrentLocation();
};
return (
<div className={styles.locationInputContainer}>
<div className={styles.fieldsRow}>
<div className={styles.fieldWrapper}>
<TextField
value={adresse}
onChange={setAdresse}
placeholder="z.B. Bundesplatz 3"
label="Adresse oder Parzelle"
disabled={isGettingLocation || isSearchingParcel}
size="md"
type="text"
name="adresse"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
}}
/>
</div>
<div className={styles.buttonsWrapper}>
<Button
variant="primary"
size="md"
icon={IoMdSend}
onClick={handleSearch}
disabled={!buildLocationString().trim() || isGettingLocation || isSearchingParcel}
loading={isSearchingParcel}
className={styles.searchButton}
>
Suchen
</Button>
<Button
variant="secondary"
size="md"
icon={FaLocationArrow}
onClick={handleUseCurrentLocation}
disabled={isGettingLocation || isSearchingParcel}
loading={isGettingLocation}
className={styles.locationButton}
>
Meine Position
</Button>
</div>
</div>
</div>
);
};
export default PekLocationInput;

View file

@ -1,59 +0,0 @@
import React from 'react';
import { MapView, ParcelInfoPanel } from '../../../../../components/UiComponents';
import { usePekContext } from '../../../../../contexts/PekContext';
const PekMapView: React.FC = () => {
const {
mapCenter,
mapZoomBounds,
parcelGeometries,
handleMapClick,
handleParcelClick,
selectedParcels,
removeParcel,
isPanelOpen,
setIsPanelOpen
} = usePekContext();
// Aggregate all adjacent parcels from all selected parcels
const allAdjacentParcels = React.useMemo(() => {
const adjacentSet = new Map<string, any>();
selectedParcels.forEach(parcel => {
if (parcel.adjacent_parcels) {
parcel.adjacent_parcels.forEach((adj: { id: string }) => {
if (!adjacentSet.has(adj.id)) {
adjacentSet.set(adj.id, adj);
}
});
}
});
return Array.from(adjacentSet.values());
}, [selectedParcels]);
return (
<>
<div style={{ marginBottom: '1.5rem' }}>
<MapView
parcels={parcelGeometries}
center={mapCenter || undefined}
zoomBounds={mapZoomBounds || undefined}
onMapClick={handleMapClick}
onParcelClick={handleParcelClick}
height="600px"
emptyMessage="Klicken Sie auf die Karte, um einen Standort auszuwählen, oder suchen Sie nach einer Adresse oben."
/>
</div>
<ParcelInfoPanel
isOpen={isPanelOpen}
onClose={() => setIsPanelOpen(false)}
parcels={selectedParcels}
onRemoveParcel={removeParcel}
adjacentParcels={allAdjacentParcels}
/>
</>
);
};
export default PekMapView;

View file

@ -1,18 +0,0 @@
import React from 'react';
import { PekProvider } from '../../../../../contexts/PekContext';
import PageRenderer from '../../../PageRenderer';
import { pekPageData } from '../pek';
const PekPageWrapper: React.FC = () => {
// Create a version of pageData without customComponent to avoid infinite loop
const { customComponent, ...pageDataWithoutCustom } = pekPageData;
return (
<PekProvider>
<PageRenderer pageData={pageDataWithoutCustom} />
</PekProvider>
);
};
export default PekPageWrapper;

View file

@ -1,263 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../pageInterface';
import { FaLightbulb, FaPlus } from 'react-icons/fa';
import { usePrompts, usePromptOperations } from '../../../../hooks/usePrompts';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
// Check if this is a date/timestamp field - disable filtering for these
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,
// Disable filtering for date/timestamp fields
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
});
};
// Hook factory function for prompts data
const createPromptsHook = () => {
return () => {
const {
prompts,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchPromptById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = usePrompts();
const {
handlePromptDelete,
handlePromptCreate,
handlePromptUpdate,
handleInlineUpdate,
deletingPrompts,
creatingPrompt,
deleteError,
createError,
updateError
} = usePromptOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
// Wrapped create handler that adds mandateId from currentUser cache
const wrappedHandlePromptCreate = useCallback(async (formData: { name: string; content: string }) => {
// mandateId is retrieved inside handlePromptCreate from sessionStorage cache
return await handlePromptCreate({ name: formData.name, content: formData.content });
}, [handlePromptCreate]);
// Handle single prompt deletion for FormGenerator
const handleDeleteSingle = useCallback(async (prompt: any) => {
const success = await handlePromptDelete(prompt.id);
if (success) {
refetch();
}
}, [handlePromptDelete, refetch]);
// Handle multiple prompt deletion for FormGenerator
const handleDeleteMultiple = useCallback(async (selectedPrompts: any[]) => {
const promptIds = selectedPrompts.map(prompt => prompt.id);
const results = await Promise.all(
promptIds.map(id => handlePromptDelete(id))
);
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handlePromptDelete, refetch]);
return {
data: prompts,
loading,
error,
refetch,
removeOptimistically, // Expose optimistic removal (generic name for DeleteActionButton)
updateOptimistically, // Expose optimistic update for EditActionButton
// Operations
handleDelete: handlePromptDelete,
handleDeleteMultiple,
handlePromptCreate: wrappedHandlePromptCreate, // Use wrapped version
handlePromptUpdate,
handleInlineUpdate, // For inline boolean editing in table
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
// Loading states
deletingPrompts,
creatingPrompt,
// Error states
deleteError,
createError,
updateError,
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns, // Return generated columns
// Functions for EditActionButton
fetchPromptById, // Fetch single prompt by ID
generateEditFieldsFromAttributes, // Generate edit fields from attributes
generateCreateFieldsFromAttributes, // Generate create fields from attributes
ensureAttributesLoaded // Generic function to ensure attributes are loaded
};
};
};
export const promptsPageData: GenericPageData = {
id: 'basedata-prompts',
path: 'basedata/prompts',
name: 'prompts.title',
description: 'prompts.description',
// Parent page - now under 'basedata' group (formerly 'administration')
parentPath: 'basedata',
// Visual
icon: FaLightbulb,
title: 'prompts.title',
subtitle: 'prompts.subtitle',
// Header buttons
headerButtons: [
{
id: 'new-prompt',
label: 'prompts.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
// Fields will be generated dynamically from attributes via generateCreateFieldsFromAttributes
// PageRenderer will use generateCreateFieldsFromAttributes if available, otherwise generateEditFieldsFromAttributes
fields: [], // Empty array - fields will be generated dynamically from attributes
popupTitle: 'prompts.modal.create.title',
popupSize: 'medium',
createOperationName: 'handlePromptCreate',
successMessage: 'prompts.create.success',
errorMessage: 'prompts.create.error'
}
}
],
// Content sections - using generic table approach
content: [
{
id: 'prompts-table',
type: 'table',
tableConfig: {
hookFactory: createPromptsHook,
// Columns are generated dynamically from attributes via hookData.columns
actionButtons: [
{
type: 'play',
title: 'prompts.action.start',
idField: 'id',
nameField: 'name',
contentField: 'content',
mode: 'prompt',
navigateTo: 'start/dashboard',
// Only show if user has read permission (permissions.read !== 'n')
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasRead = hookData.permissions.read !== 'n' && hookData.permissions.view;
return { disabled: !hasRead, message: 'No permission to start prompts' };
}
},
{
type: 'edit',
title: 'prompts.action.edit',
idField: 'id',
nameField: 'name',
operationName: 'handlePromptUpdate',
loadingStateName: 'updatingPrompts',
fetchItemFunctionName: 'fetchPromptById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit prompts' };
}
},
{
type: 'delete',
title: 'prompts.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingPrompts', // Entity-specific loading state
// Only show if user has delete permission (permissions.delete !== 'n')
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 prompts' };
}
},
{
type: 'copy',
title: 'prompts.action.copy',
idField: 'id',
nameField: 'name',
contentField: 'content',
operationName: 'handlePromptCreate',
loadingStateName: 'creatingPrompt',
// Only show if user has create permission (permissions.create !== 'n')
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasCreate = hookData.permissions.create !== 'n' && hookData.permissions.view;
return { disabled: !hasCreate, message: 'No permission to create prompts' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'prompts-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true, // Keep page mounted and prevent refetching
moduleEnabled: true,
// shown in sidebar under administration
// No drag and drop configuration for prompts
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Prompts activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Prompts loaded - can initialize prompts list here');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Prompts unloaded - cleanup prompts references');
}
};

View file

@ -1,128 +0,0 @@
import { GenericPageData } from '../../pageInterface';
import { FaCog } from 'react-icons/fa';
import { createSettingsHook } from '../../../../hooks/useSettings';
export const settingsPageData: GenericPageData = {
id: 'settings',
path: 'settings',
name: 'Settings',
description: 'Manage your account settings and preferences',
// Visual
icon: FaCog,
title: 'settings.title',
subtitle: 'Manage your account settings and preferences',
// Page behavior
persistent: false,
preserveState: false,
preload: true,
moduleEnabled: true,
// Sidebar
showInSidebar: true,
order: 999, // Put at the end
// Content sections
content: [
{
id: 'settings-content',
type: 'settings',
settingsConfig: {
sections: [
{
id: 'user-info',
sectionId: 'user-info',
title: 'settings.userinfo',
description: 'settings.userinfo.description',
saveButtonLabel: 'settings.userinfo.save',
saveButtonVariant: 'primary',
saveButtonSize: 'md',
// Static fields for user information
staticFields: [
{
id: 'username',
type: 'text',
label: 'settings.userinfo.username',
description: 'settings.userinfo.username.description',
dataKey: 'username',
required: true,
placeholder: 'settings.userinfo.username'
},
{
id: 'fullName',
type: 'text',
label: 'settings.userinfo.fullname',
description: 'settings.userinfo.fullname.description',
dataKey: 'fullName',
required: false,
placeholder: 'settings.userinfo.fullname'
},
{
id: 'email',
type: 'text',
inputType: 'email',
label: 'settings.userinfo.email',
description: 'settings.userinfo.email.description',
dataKey: 'email',
required: true,
placeholder: 'settings.userinfo.email'
},
{
id: 'language',
type: 'select',
label: 'settings.userinfo.language',
description: 'settings.userinfo.language.description',
dataKey: 'language',
required: false,
options: [
{ id: 'en', label: 'English', value: 'en' },
{ id: 'de', label: 'Deutsch', value: 'de' },
{ id: 'fr', label: 'Français', value: 'fr' },
{ id: 'it', label: 'Italiano', value: 'it' }
]
},
{
id: 'phone-name',
type: 'text',
label: 'settings.userinfo.phone_name',
description: 'settings.userinfo.phone_name.description',
dataKey: 'phoneName',
required: false,
placeholder: 'settings.userinfo.phone_name'
}
]
},
{
id: 'theme',
title: '',
sectionId: 'theme',
saveButtonLabel: 'settings.save',
saveButtonVariant: 'primary',
saveButtonSize: 'md',
staticFields: [
{
id: 'theme-select',
type: 'select',
label: 'settings.theme',
dataKey: 'theme',
required: false,
options: [
{ id: 'light', label: 'settings.theme.light', value: 'light' },
{ id: 'dark', label: 'settings.theme.dark', value: 'dark' }
]
}
]
}
],
hookFactory: createSettingsHook
}
}
],
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Settings page activated');
}
};

View file

@ -1,223 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaKey, FaPlus } from 'react-icons/fa';
import { useTrusteeAccess, useTrusteeAccessOperations } from '../../../../../hooks/useTrustee';
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(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
};
});
};
const createAccessHook = () => {
return () => {
const {
items: accessRecords,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteeAccess();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError
} = useTrusteeAccessOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: accessRecords,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteeAccessPageData: GenericPageData = {
id: 'trustee-access',
path: 'trustee/access',
name: 'trustee.access.title',
description: 'trustee.access.description',
parentPath: 'start.trustee',
icon: FaKey,
title: 'trustee.access.title',
subtitle: 'trustee.access.subtitle',
headerButtons: [
{
id: 'new-access',
label: 'trustee.access.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'organisationId',
label: 'trustee.access.field.organisationId',
type: 'enum',
required: true,
optionsReference: 'TrusteeOrganisation'
},
{
key: 'roleId',
label: 'trustee.access.field.roleId',
type: 'enum',
required: true,
optionsReference: 'TrusteeRole'
},
{
key: 'userId',
label: 'trustee.access.field.userId',
type: 'enum',
required: true,
optionsReference: 'User'
},
{
key: 'contractId',
label: 'trustee.access.field.contractId',
type: 'enum',
required: false,
optionsReference: 'TrusteeContract',
placeholder: 'trustee.access.field.contractId_placeholder'
}
],
popupTitle: 'trustee.access.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleCreate',
successMessage: 'trustee.access.create.success',
errorMessage: 'trustee.access.create.error'
}
}
],
content: [
{
id: 'access-table',
type: 'table',
tableConfig: {
hookFactory: createAccessHook,
actionButtons: [
{
type: 'edit',
title: 'trustee.access.action.edit',
idField: 'id',
nameField: 'id',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
fetchItemFunctionName: 'fetchById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit access' };
}
},
{
type: 'delete',
title: 'trustee.access.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
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 access' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'trustee-access-table'
}
}
],
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
onActivate: async () => {
if (import.meta.env.DEV) console.log('Trustee Access activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Trustee Access loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Trustee Access unloaded');
}
};

View file

@ -1,214 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaFileContract, FaPlus } from 'react-icons/fa';
import { useTrusteeContracts, useTrusteeContractOperations } from '../../../../../hooks/useTrustee';
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(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
};
});
};
const createContractsHook = () => {
return () => {
const {
items: contracts,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteeContracts();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError
} = useTrusteeContractOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: contracts,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteeContractsPageData: GenericPageData = {
id: 'trustee-contracts',
path: 'trustee/contracts',
name: 'trustee.contracts.title',
description: 'trustee.contracts.description',
parentPath: 'start.trustee',
icon: FaFileContract,
title: 'trustee.contracts.title',
subtitle: 'trustee.contracts.subtitle',
headerButtons: [
{
id: 'new-contract',
label: 'trustee.contracts.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'organisationId',
label: 'trustee.contracts.field.organisationId',
type: 'enum',
required: true,
optionsReference: 'TrusteeOrganisation'
},
{
key: 'label',
label: 'trustee.contracts.field.label',
type: 'string',
required: true,
placeholder: 'trustee.contracts.field.label_placeholder'
},
{
key: 'enabled',
label: 'trustee.contracts.field.enabled',
type: 'boolean',
required: false
}
],
popupTitle: 'trustee.contracts.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleCreate',
successMessage: 'trustee.contracts.create.success',
errorMessage: 'trustee.contracts.create.error'
}
}
],
content: [
{
id: 'contracts-table',
type: 'table',
tableConfig: {
hookFactory: createContractsHook,
actionButtons: [
{
type: 'edit',
title: 'trustee.contracts.action.edit',
idField: 'id',
nameField: 'label',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
fetchItemFunctionName: 'fetchById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit contracts' };
}
},
{
type: 'delete',
title: 'trustee.contracts.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
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 contracts' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'trustee-contracts-table'
}
}
],
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
onActivate: async () => {
if (import.meta.env.DEV) console.log('Trustee Contracts activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Trustee Contracts loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Trustee Contracts unloaded');
}
};

View file

@ -1,232 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaFile, FaPlus } from 'react-icons/fa';
import { useTrusteeDocuments, useTrusteeDocumentOperations } from '../../../../../hooks/useTrustee';
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name);
// Hide binary data column
if (attr.name === 'documentData') {
return null;
}
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
};
}).filter(Boolean);
};
const createDocumentsHook = () => {
return () => {
const {
items: documents,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteeDocuments();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError
} = useTrusteeDocumentOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: documents,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteeDocumentsPageData: GenericPageData = {
id: 'trustee-documents',
path: 'trustee/documents',
name: 'trustee.documents.title',
description: 'trustee.documents.description',
parentPath: 'start.trustee',
icon: FaFile,
title: 'trustee.documents.title',
subtitle: 'trustee.documents.subtitle',
headerButtons: [
{
id: 'new-document',
label: 'trustee.documents.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'organisationId',
label: 'trustee.documents.field.organisationId',
type: 'enum',
required: true,
optionsReference: 'TrusteeOrganisation'
},
{
key: 'contractId',
label: 'trustee.documents.field.contractId',
type: 'enum',
required: true,
optionsReference: 'TrusteeContract'
},
{
key: 'documentName',
label: 'trustee.documents.field.documentName',
type: 'string',
required: true,
placeholder: 'trustee.documents.field.documentName_placeholder'
},
{
key: 'documentMimeType',
label: 'trustee.documents.field.documentMimeType',
type: 'enum',
required: true,
options: [
{ value: 'application/pdf', label: 'PDF' },
{ value: 'image/jpeg', label: 'JPEG' },
{ value: 'image/png', label: 'PNG' },
{ value: 'application/octet-stream', label: 'Other' }
]
}
],
popupTitle: 'trustee.documents.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleCreate',
successMessage: 'trustee.documents.create.success',
errorMessage: 'trustee.documents.create.error'
}
}
],
content: [
{
id: 'documents-table',
type: 'table',
tableConfig: {
hookFactory: createDocumentsHook,
actionButtons: [
{
type: 'edit',
title: 'trustee.documents.action.edit',
idField: 'id',
nameField: 'documentName',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
fetchItemFunctionName: 'fetchById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit documents' };
}
},
{
type: 'delete',
title: 'trustee.documents.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
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 documents' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'trustee-documents-table'
}
}
],
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
onActivate: async () => {
if (import.meta.env.DEV) console.log('Trustee Documents activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Trustee Documents loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Trustee Documents unloaded');
}
};

View file

@ -1,31 +0,0 @@
import { GenericPageData } from '../../../pageInterface';
// Import all trustee page configurations
import { trusteeOrganisationsPageData } from './organisations';
import { trusteeRolesPageData } from './roles';
import { trusteeAccessPageData } from './access';
import { trusteeContractsPageData } from './contracts';
import { trusteeDocumentsPageData } from './documents';
import { trusteePositionsPageData } from './positions';
// Export all trustee pages
export {
trusteeOrganisationsPageData,
trusteeRolesPageData,
trusteeAccessPageData,
trusteeContractsPageData,
trusteeDocumentsPageData,
trusteePositionsPageData
};
// Export array of all trustee pages for registration
// No explicit container needed - SidebarProvider creates a virtual parent group
// based on parentPath: 'trustee' in the child pages
export const trusteePages: GenericPageData[] = [
trusteeOrganisationsPageData,
trusteeRolesPageData,
trusteeAccessPageData,
trusteeContractsPageData,
trusteeDocumentsPageData,
trusteePositionsPageData
];

View file

@ -1,228 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaBuilding, FaPlus } from 'react-icons/fa';
import { useTrusteeOrganisations, useTrusteeOrganisationOperations } from '../../../../../hooks/useTrustee';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(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 organisations data
const createOrganisationsHook = () => {
return () => {
const {
items: organisations,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteeOrganisations();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError
} = useTrusteeOrganisationOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: organisations,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteeOrganisationsPageData: GenericPageData = {
id: 'trustee-organisations',
path: 'trustee/organisations',
name: 'trustee.organisations.title',
description: 'trustee.organisations.description',
parentPath: 'start.trustee',
icon: FaBuilding,
title: 'trustee.organisations.title',
subtitle: 'trustee.organisations.subtitle',
headerButtons: [
{
id: 'new-organisation',
label: 'trustee.organisations.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'id',
label: 'trustee.organisations.field.id',
type: 'string',
required: true,
placeholder: 'trustee.organisations.field.id_placeholder',
validator: (value: string) => {
if (!value || value.trim() === '') {
return 'Organisation ID cannot be empty';
}
if (value.length < 3 || value.length > 50) {
return 'Organisation ID must be 3-50 characters';
}
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
return 'Organisation ID can only contain letters, numbers, hyphens, and underscores';
}
return null;
}
},
{
key: 'label',
label: 'trustee.organisations.field.label',
type: 'string',
required: true,
placeholder: 'trustee.organisations.field.label_placeholder'
},
{
key: 'enabled',
label: 'trustee.organisations.field.enabled',
type: 'boolean',
required: false
}
],
popupTitle: 'trustee.organisations.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleCreate',
successMessage: 'trustee.organisations.create.success',
errorMessage: 'trustee.organisations.create.error'
}
}
],
content: [
{
id: 'organisations-table',
type: 'table',
tableConfig: {
hookFactory: createOrganisationsHook,
actionButtons: [
{
type: 'edit',
title: 'trustee.organisations.action.edit',
idField: 'id',
nameField: 'label',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
fetchItemFunctionName: 'fetchById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit organisations' };
}
},
{
type: 'delete',
title: 'trustee.organisations.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
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 organisations' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'trustee-organisations-table'
}
}
],
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
onActivate: async () => {
if (import.meta.env.DEV) console.log('Trustee Organisations activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Trustee Organisations loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Trustee Organisations unloaded');
}
};

View file

@ -1,281 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaReceipt, FaPlus } from 'react-icons/fa';
import { useTrusteePositions, useTrusteePositionOperations } from '../../../../../hooks/useTrustee';
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(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
};
});
};
const createPositionsHook = () => {
return () => {
const {
items: positions,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteePositions();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError
} = useTrusteePositionOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
// Auto-calculate VAT amount if not provided
if (formData.bookingAmount && formData.vatPercentage && !formData.vatAmount) {
formData.vatAmount = formData.bookingAmount * formData.vatPercentage / 100;
}
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: positions,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteePositionsPageData: GenericPageData = {
id: 'trustee-positions',
path: 'trustee/positions',
name: 'trustee.positions.title',
description: 'trustee.positions.description',
parentPath: 'start.trustee',
icon: FaReceipt,
title: 'trustee.positions.title',
subtitle: 'trustee.positions.subtitle',
headerButtons: [
{
id: 'new-position',
label: 'trustee.positions.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'organisationId',
label: 'trustee.positions.field.organisationId',
type: 'enum',
required: true,
optionsReference: 'TrusteeOrganisation'
},
{
key: 'contractId',
label: 'trustee.positions.field.contractId',
type: 'enum',
required: true,
optionsReference: 'TrusteeContract'
},
{
key: 'valuta',
label: 'trustee.positions.field.valuta',
type: 'date',
required: true
},
{
key: 'company',
label: 'trustee.positions.field.company',
type: 'string',
required: false,
placeholder: 'trustee.positions.field.company_placeholder'
},
{
key: 'desc',
label: 'trustee.positions.field.desc',
type: 'textarea',
required: false,
minRows: 2,
maxRows: 4
},
{
key: 'bookingCurrency',
label: 'trustee.positions.field.bookingCurrency',
type: 'enum',
required: true,
options: [
{ value: 'CHF', label: 'CHF' },
{ value: 'EUR', label: 'EUR' },
{ value: 'USD', label: 'USD' },
{ value: 'GBP', label: 'GBP' }
]
},
{
key: 'bookingAmount',
label: 'trustee.positions.field.bookingAmount',
type: 'number',
required: true
},
{
key: 'originalCurrency',
label: 'trustee.positions.field.originalCurrency',
type: 'enum',
required: true,
options: [
{ value: 'CHF', label: 'CHF' },
{ value: 'EUR', label: 'EUR' },
{ value: 'USD', label: 'USD' },
{ value: 'GBP', label: 'GBP' }
]
},
{
key: 'originalAmount',
label: 'trustee.positions.field.originalAmount',
type: 'number',
required: true
},
{
key: 'vatPercentage',
label: 'trustee.positions.field.vatPercentage',
type: 'number',
required: false
},
{
key: 'vatAmount',
label: 'trustee.positions.field.vatAmount',
type: 'number',
required: false
}
],
popupTitle: 'trustee.positions.modal.create.title',
popupSize: 'large',
createOperationName: 'handleCreate',
successMessage: 'trustee.positions.create.success',
errorMessage: 'trustee.positions.create.error'
}
}
],
content: [
{
id: 'positions-table',
type: 'table',
tableConfig: {
hookFactory: createPositionsHook,
actionButtons: [
{
type: 'edit',
title: 'trustee.positions.action.edit',
idField: 'id',
nameField: 'desc',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
fetchItemFunctionName: 'fetchById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit positions' };
}
},
{
type: 'delete',
title: 'trustee.positions.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
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 positions' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'trustee-positions-table'
}
}
],
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
onActivate: async () => {
if (import.meta.env.DEV) console.log('Trustee Positions activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Trustee Positions loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Trustee Positions unloaded');
}
};

View file

@ -1,210 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../../pageInterface';
import { FaUserTag, FaPlus } from 'react-icons/fa';
import { useTrusteeRoles, useTrusteeRoleOperations } from '../../../../../hooks/useTrustee';
const attributesToColumns = (attributes: any[]) => {
return attributes.map(attr => {
const isDateField = attr.type === 'date' || attr.type === 'timestamp' ||
/(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
};
});
};
const createRolesHook = () => {
return () => {
const {
items: roles,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
} = useTrusteeRoles();
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
deleteError,
createError,
updateError
} = useTrusteeRoleOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
const wrappedHandleCreate = useCallback(async (formData: any) => {
return await handleCreate(formData);
}, [handleCreate]);
const handleDeleteSingle = useCallback(async (item: any) => {
const success = await handleDelete(item.id);
if (success) {
refetch();
}
}, [handleDelete, refetch]);
const handleDeleteMultiple = useCallback(async (selectedItems: any[]) => {
const ids = selectedItems.map(item => item.id);
const results = await Promise.all(ids.map(id => handleDelete(id)));
const allSuccessful = results.every(result => result);
if (allSuccessful) {
refetch();
}
}, [handleDelete, refetch]);
return {
data: roles,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
handleDelete,
handleDeleteMultiple,
handleCreate: wrappedHandleCreate,
handleUpdate,
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
deletingItems,
creatingItem,
deleteError,
createError,
updateError,
attributes,
permissions,
columns: generatedColumns,
fetchById,
generateEditFieldsFromAttributes,
generateCreateFieldsFromAttributes,
ensureAttributesLoaded
};
};
};
export const trusteeRolesPageData: GenericPageData = {
id: 'trustee-roles',
path: 'trustee/roles',
name: 'trustee.roles.title',
description: 'trustee.roles.description',
parentPath: 'start.trustee',
icon: FaUserTag,
title: 'trustee.roles.title',
subtitle: 'trustee.roles.subtitle',
headerButtons: [
{
id: 'new-role',
label: 'trustee.roles.new_button',
icon: FaPlus,
variant: 'primary',
formConfig: {
fields: [
{
key: 'id',
label: 'trustee.roles.field.id',
type: 'string',
required: true,
placeholder: 'trustee.roles.field.id_placeholder'
},
{
key: 'desc',
label: 'trustee.roles.field.desc',
type: 'textarea',
required: true,
placeholder: 'trustee.roles.field.desc_placeholder',
minRows: 3,
maxRows: 6
}
],
popupTitle: 'trustee.roles.modal.create.title',
popupSize: 'medium',
createOperationName: 'handleCreate',
successMessage: 'trustee.roles.create.success',
errorMessage: 'trustee.roles.create.error'
}
}
],
content: [
{
id: 'roles-table',
type: 'table',
tableConfig: {
hookFactory: createRolesHook,
actionButtons: [
{
type: 'edit',
title: 'trustee.roles.action.edit',
idField: 'id',
nameField: 'id',
operationName: 'handleUpdate',
loadingStateName: 'updatingItems',
fetchItemFunctionName: 'fetchById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit roles' };
}
},
{
type: 'delete',
title: 'trustee.roles.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingItems',
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 roles' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'trustee-roles-table'
}
}
],
persistent: false,
preload: false,
preserveState: true,
moduleEnabled: true,
onActivate: async () => {
if (import.meta.env.DEV) console.log('Trustee Roles activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Trustee Roles loaded');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Trustee Roles unloaded');
}
};

View file

@ -1,243 +0,0 @@
import { useCallback } from 'react';
import { GenericPageData } from '../../pageInterface';
import { FaProjectDiagram } from 'react-icons/fa';
import { useUserWorkflows, useWorkflowOperations } from '../../../../hooks/useWorkflows';
// Helper function to convert attribute definitions to column config
const attributesToColumns = (attributes: any[]) => {
return attributes
.filter(attr => {
// Exclude statistic, logs, messages, tasks, max steps, current action, total tasks, and total actions columns entirely
const attrNameLower = attr.name.toLowerCase();
const excludedColumns = [
'statistic', 'statistics', 'stats',
'logs', 'log',
'messages', 'message',
'tasks', 'task',
'maxsteps', 'max_steps', 'max steps', 'maxstep', 'max_step',
'currentaction', 'current_action', 'current action',
'totaltasks', 'total_tasks', 'total tasks',
'totalactions', 'total_actions', 'total actions',
'expectedformats', 'expected_formats', 'expected formats', 'expectedformat', 'expected_format', 'expected format'
];
return !excludedColumns.includes(attrNameLower);
})
.map(attr => {
// Check if this is a date/timestamp field - disable filtering for these
const attrNameLower = attr.name.toLowerCase();
const isDateField = attr.type === 'date' ||
/(date|Date|timestamp|Timestamp|created|Created|updated|Updated|at|At)$/i.test(attr.name) ||
attrNameLower === 'lastactivity' ||
attrNameLower === 'last_activity' ||
attrNameLower === 'last activity';
const column: any = {
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,
// Disable filtering for date/timestamp fields
filterable: isDateField ? false : (attr.filterable !== false),
searchable: attr.searchable !== false,
filterOptions: attr.filterOptions
};
// Add explicit formatters for numeric fields that should NOT be treated as dates
if (attrNameLower === 'currentround' || attrNameLower === 'current_round' || attrNameLower === 'current round') {
column.formatter = (value: any) => {
if (value === null || value === undefined) return '-';
return typeof value === 'number' ? value.toString() : value;
};
// Ensure type is set to number to prevent date formatting
column.type = 'number';
} else if (attrNameLower === 'currenttask' || attrNameLower === 'current_task' || attrNameLower === 'current task') {
column.formatter = (value: any) => {
if (value === null || value === undefined) return '-';
return typeof value === 'number' ? value.toString() : value;
};
// Ensure type is set to number to prevent date formatting
column.type = 'number';
}
return column;
});
};
// Hook factory function for workflows data
const createWorkflowsHook = () => {
return () => {
const {
data: workflows,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
attributes,
permissions,
pagination,
fetchWorkflowById,
generateEditFieldsFromAttributes,
ensureAttributesLoaded
} = useUserWorkflows();
const {
handleWorkflowDelete,
handleWorkflowDeleteMultiple,
handleWorkflowUpdate,
deletingWorkflows,
editingWorkflows
} = useWorkflowOperations();
const generatedColumns = attributes && attributes.length > 0
? attributesToColumns(attributes)
: undefined;
// Handle single workflow deletion for FormGenerator
const handleDeleteSingle = useCallback(async (workflow: any) => {
const success = await handleWorkflowDelete(workflow.id);
if (success) {
refetch();
}
}, [handleWorkflowDelete, refetch]);
// Handle multiple workflow deletion for FormGenerator
const handleDeleteMultiple = useCallback(async (selectedWorkflows: any[]) => {
const workflowIds = selectedWorkflows.map(workflow => workflow.id);
const success = await handleWorkflowDeleteMultiple(workflowIds);
if (success) {
refetch();
}
}, [handleWorkflowDeleteMultiple, refetch]);
return {
data: workflows,
loading,
error,
refetch,
removeOptimistically,
updateOptimistically,
// Operations
handleDelete: handleWorkflowDelete,
handleDeleteMultiple,
handleWorkflowUpdate,
// FormGenerator specific handlers
onDelete: handleDeleteSingle,
onDeleteMultiple: handleDeleteMultiple,
// Loading states
deletingWorkflows,
editingWorkflows,
// Attributes and permissions for dynamic column/button generation
attributes,
permissions,
pagination, // Pagination metadata from backend
columns: generatedColumns, // Return generated columns
// Functions for EditActionButton
fetchWorkflowById, // Fetch single workflow by ID
generateEditFieldsFromAttributes, // Generate edit fields from attributes
ensureAttributesLoaded // Generic function to ensure attributes are loaded
};
};
};
export const workflowsPageData: GenericPageData = {
id: 'workflows-list',
path: 'workflows/list',
name: 'workflows.title',
description: 'workflows.description',
// Parent page - now under 'workflows' group
parentPath: 'workflows',
// Visual
icon: FaProjectDiagram,
title: 'workflows.title',
subtitle: 'workflows.title',
// Header buttons - none for now, workflows are typically created through the playground
// Content sections - using generic table approach
content: [
{
id: 'workflows-table',
type: 'table',
tableConfig: {
hookFactory: createWorkflowsHook,
// Columns are generated dynamically from attributes via hookData.columns
actionButtons: [
{
type: 'play',
title: 'workflows.action.play',
idField: 'id',
nameField: 'name',
navigateTo: 'start/dashboard',
mode: 'workflow', // Set mode to 'workflow' to select workflow instead of setting prompt
// Only show if user has read permission (permissions.read !== 'n')
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasRead = hookData.permissions.read !== 'n' && hookData.permissions.view;
return { disabled: !hasRead, message: 'No permission to start workflows' };
}
},
{
type: 'edit',
title: 'workflows.action.edit',
idField: 'id',
nameField: 'name',
operationName: 'handleWorkflowUpdate',
loadingStateName: 'editingWorkflows',
fetchItemFunctionName: 'fetchWorkflowById',
disabled: (hookData: any) => {
if (!hookData?.permissions) return { disabled: false };
const hasUpdate = hookData.permissions.update !== 'n' && hookData.permissions.view;
return { disabled: !hasUpdate, message: 'No permission to edit workflows' };
}
},
{
type: 'delete',
title: 'workflows.action.delete',
idField: 'id',
operationName: 'handleDelete',
loadingStateName: 'deletingWorkflows',
// Only show if user has delete permission (permissions.delete !== 'n')
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 workflows' };
}
}
],
searchable: true,
filterable: true,
sortable: true,
resizable: true,
pagination: true,
pageSize: 10,
className: 'workflows-table'
}
}
],
// Page behavior
persistent: false,
preload: false,
preserveState: true, // Keep page mounted and prevent refetching
moduleEnabled: true,
// Sidebar - will be shown as subpage under Administration
// Lifecycle hooks
onActivate: async () => {
if (import.meta.env.DEV) console.log('Workflows activated');
},
onLoad: async () => {
if (import.meta.env.DEV) console.log('Workflows loaded - can initialize workflow lists here');
},
onUnload: async () => {
if (import.meta.env.DEV) console.log('Workflows unloaded - cleanup workflow references');
}
};

View file

@ -1,30 +0,0 @@
/**
* @deprecated This PageManager system is deprecated.
*
* New pages should be created in src/pages/ and use:
* - src/components/Navigation/MandateNavigation.tsx for navigation
* - src/App.tsx for routing
*
* Migration targets (new location):
* - workflows /workflows/list
* - automations /workflows/automations
* - playground /workflows/playground
* - prompts /basedata/prompts
* - files /basedata/files
* - connections /basedata/connections
* - chatbot /chatbot (migrate to feature)
* - pek /pek (migrate to feature)
* - speech /speech (migrate to feature)
*
* This module is kept for backward compatibility with Sidebar.tsx
* and will be fully removed in a future release.
*/
// Export the page management system (DEPRECATED)
export { default as PageManager } from './PageManager';
export { default as PageRenderer } from './PageRenderer';
export { default as SidebarProvider } from './SidebarProvider';
// Export data and interfaces (DEPRECATED)
export * from './data';
export * from './pageInterface';

View file

@ -1,429 +0,0 @@
import React from 'react';
import { IconType } from 'react-icons';
import { DragDropConfig } from '../../components/UiComponents/DragDropOverlay/DragDropOverlay';
// Generic privilege checker function type
export type PrivilegeChecker = () => boolean | Promise<boolean>;
// Form field configuration for create/edit buttons
export interface ButtonFormField {
key: string;
label: string | LanguageText;
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly' | 'multiselect' | 'number';
required?: boolean;
placeholder?: string | LanguageText;
minRows?: number;
maxRows?: number;
validator?: (value: any) => string | null;
defaultValue?: any;
options?: string[] | Array<{ value: string | number; label: string }>; // For enum/multiselect fields
optionsReference?: string; // Reference to a data source for dynamic options (e.g., 'TrusteeOrganisation', 'User')
}
// Dropdown configuration for header dropdown buttons
export interface DropdownConfig<T = any> {
type: 'dropdown';
items: Array<{
id: string | number;
label: string | LanguageText;
value: T;
metadata?: Record<string, any>;
}>;
selectedItemId?: string | number | null;
onSelect: (item: { id: string | number; label: string | LanguageText; value: T; metadata?: Record<string, any> } | null, hookData?: any) => void | Promise<void>;
placeholder?: string | LanguageText;
emptyMessage?: string | LanguageText;
headerText?: string | LanguageText;
// Optional: name of property in hookData that provides items, selectedItemId, and onSelect
dataSource?: {
itemsProperty?: string; // Property name in hookData that contains items array
selectedIdProperty?: string; // Property name in hookData that contains selectedItemId
onSelectMethod?: string; // Method name in hookData for onSelect callback
loadingProperty?: string; // Property name in hookData that contains loading state
};
}
// Button configuration for header actions
export interface PageButton {
id: string;
label: string | LanguageText;
variant?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning';
size?: 'sm' | 'md' | 'lg';
icon?: IconType;
onClick?: (hookData?: any) => void | Promise<void>;
disabled?: boolean | ((hookData?: any) => boolean | { disabled: boolean; message?: string });
// Form configuration for create buttons
formConfig?: {
fields: ButtonFormField[];
popupTitle?: string | LanguageText;
popupSize?: 'small' | 'medium' | 'large';
createOperationName?: string; // Name of the create operation in hookData (e.g., 'handlePromptCreate')
successMessage?: string | LanguageText;
errorMessage?: string | LanguageText;
multiStep?: boolean; // Enable multi-step form mode
};
// Dropdown configuration for dropdown selection buttons
dropdownConfig?: DropdownConfig;
}
// Input form configuration
export interface InputFormConfig {
hookFactory?: () => () => GenericDataHook; // Optional, can use page-level hook from tableConfig if exists
placeholder?: string | LanguageText;
buttonLabel?: string | LanguageText;
stopButtonLabel?: string | LanguageText; // Label for stop button when workflow is running
buttonIcon?: IconType;
stopButtonIcon?: IconType; // Icon for stop button when workflow is running
buttonVariant?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning';
stopButtonVariant?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning'; // Variant for stop button
buttonSize?: 'sm' | 'md' | 'lg';
textFieldSize?: 'sm' | 'md' | 'lg';
showFileUpload?: boolean; // Whether to show file upload button (default: true if hook provides file upload)
}
// Settings field configuration
export interface SettingsFieldConfig {
id: string;
type: 'text' | 'select' | 'toggle';
label: string | LanguageText;
description?: string | LanguageText;
required?: boolean;
disabled?: boolean | ((data: any) => boolean);
inputType?: 'text' | 'email' | 'tel';
placeholder?: string | LanguageText;
options?: Array<{id: string | number; label: string | LanguageText; value: any}>;
dataKey: string; // Dot-notation path (e.g., "mandate_general.company_name")
onSave?: (value: any, data: any) => Promise<void> | void;
}
// Settings section configuration
export interface SettingsSectionConfig {
id: string;
title: string | LanguageText;
description?: string | LanguageText;
icon?: IconType; // Optional icon for section header
// Fields will be loaded from backend based on sectionId
sectionId: string; // Identifier sent to backend to fetch fields
// Save handler per section (uses Button component)
onSave?: (sectionId: string, data: any) => Promise<void>;
saveButtonLabel?: string | LanguageText;
saveButtonVariant?: 'primary' | 'secondary' | 'danger' | 'success' | 'warning';
saveButtonSize?: 'sm' | 'md' | 'lg';
// Optional: Static field overrides from config (takes precedence over backend)
staticFields?: SettingsFieldConfig[];
// Optional: Conditional rendering - if provided, section only renders if condition returns true
renderCondition?: (formData: any) => boolean;
// Optional: Alternative content to render when renderCondition returns false
renderAlternative?: (formData: any, t: (key: string) => string, resolveLanguageText: (text: string | LanguageText, t: (key: string) => string) => string) => React.ReactNode;
}
// Settings configuration
export interface SettingsConfig {
sections: SettingsSectionConfig[];
hookFactory?: () => () => GenericDataHook; // For data fetching and field loading
}
// Content section for paragraphs
export interface PageContent {
id: string;
type: 'paragraph' | 'heading' | 'list' | 'code' | 'divider' | 'custom' | 'table' | 'inputForm' | 'messages' | 'settings' | 'log' | 'tabs' | 'columns' | 'chatHistory';
content?: string | LanguageText; // Optional for dividers
level?: number; // For headings (1-6)
items?: (string | LanguageText)[]; // For lists
language?: string; // For code blocks
customComponent?: React.ComponentType<any>;
// Table-specific properties
tableConfig?: TableContentConfig;
// Input form-specific properties
inputFormConfig?: InputFormConfig;
// Messages-specific properties
messagesConfig?: {
variant?: 'chat' | 'log'; // Message display variant: 'chat' for bubble UI, 'log' for list/table UI
dataSource?: 'messages' | 'logs' | 'both'; // Data source to render: 'messages' (default), 'logs', or 'both' (logs use log variant when merged)
showDocuments?: boolean;
showMetadata?: boolean;
showProgress?: boolean;
emptyMessage?: string | LanguageText;
};
// Settings-specific properties
settingsConfig?: SettingsConfig;
// Log-specific properties
logConfig?: {
emptyMessage?: string | LanguageText;
};
// Chat history-specific properties
chatHistoryConfig?: {
emptyMessage?: string | LanguageText;
};
// Tabs-specific properties
tabsConfig?: {
tabs: Array<{
id: string;
label: string | LanguageText;
content: PageContent[]; // Nested content sections for each tab
}>;
defaultTabId?: string;
};
// Columns-specific properties
columnsConfig?: {
columns: Array<{
id: string;
width?: string; // CSS width (e.g., "3fr", "1fr", "75%", "25%")
content: PageContent[]; // Nested content sections for each column
}>;
gap?: string; // CSS gap value
};
}
// Generic hook interface for data fetching
export interface GenericDataHook {
data: any[];
loading: boolean;
isRefetching?: boolean; // True when refetching data (keeps existing data visible)
error: string | null;
refetch?: (params?: { page?: number; pageSize?: number; sort?: Array<{field: string; direction: 'asc' | 'desc'}>; filters?: any; search?: string }) => Promise<void>;
pagination?: {
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
sort?: Array<{ field: string; direction: 'asc' | 'desc' }>;
filters?: any;
} | null;
removeFileOptimistically?: (fileId: string) => void; // For optimistic updates
columns?: any[]; // Optional columns configuration
// File operations
handleUpload?: (file: File) => Promise<{ success: boolean; data: any }>; // For file upload functionality
handleFileUpload?: (file: File) => Promise<{ success: boolean; data: any }>; // Alias for handleUpload
handleDownload?: (fileId: string, fileName: string) => Promise<boolean>; // For file download functionality
handleDelete?: (fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>; // For file delete functionality
handleFileDelete?: ((fileId: string, onOptimisticDelete?: () => void) => Promise<boolean>) | ((file: any) => Promise<void>); // Can accept fileId or WorkflowFile
handlePreview?: (fileId: string, fileName: string, mimeType?: string) => Promise<any>; // For file preview functionality
// File management properties
workflowFiles?: any[]; // Files connected to workflow
pendingFiles?: any[]; // Files pending attachment
allUserFiles?: any[]; // All user files
handleFileRemove?: ((fileId: string) => Promise<void> | void) | ((file: any) => Promise<void> | void); // Can accept fileId or WorkflowFile
handleFileAttach?: (fileId: string) => Promise<void>; // Attach file to workflow (always returns Promise)
handleFileUploadAndAttach?: (file: File) => Promise<{ success: boolean; data: any }>; // Upload and attach file
uploadingFile?: boolean; // Loading state for file upload
deletingFiles?: Set<string>; // Set of file IDs being deleted
previewingFiles?: Set<string>; // Set of file IDs being previewed
removingFiles?: Set<string>; // Set of file IDs being removed
isFileAttachmentPopupOpen?: boolean; // Whether file attachment popup is open
setIsFileAttachmentPopupOpen?: (open: boolean) => void; // Set file attachment popup state
// FormGenerator specific handlers
onDelete?: (row: any) => Promise<void>; // For single item deletion
onDeleteMultiple?: (rows: any[]) => Promise<void>; // For multiple item deletion
// Input form operations
inputValue?: string;
onInputChange?: (value: string) => void;
handleSubmit?: () => Promise<void>; // No parameters, uses internal inputValue
isSubmitting?: boolean;
// Prompt selector properties
promptPermission?: {
view?: boolean;
read?: string;
};
promptItems?: Array<{ id: string | number; label: string; value: any }>;
selectedPromptId?: string | number | null;
onPromptSelect?: (item: { id: string | number; label: string; value: any } | null) => void | Promise<void>;
promptsLoading?: boolean;
// Workflow mode selector properties
workflowModeItems?: Array<{ id: string | number; label: string; value: any }>;
selectedWorkflowMode?: string | number | null;
onWorkflowModeSelect?: (item: { id: string | number; label: string; value: any } | null) => void | Promise<void>;
// Workflow lifecycle state
workflowId?: string;
workflowStatus?: string;
workflowData?: {
currentRound?: number;
[key: string]: any;
};
isRunning?: boolean;
currentRound?: number; // Current workflow round
latestStats?: any; // Latest workflow statistics
// Messages from workflow
messages?: any[];
// Logs from workflow
logs?: any[];
// Dashboard log tree
dashboardTree?: any; // Dashboard log tree structure
onToggleOperationExpanded?: (operationId: string) => void;
getChildOperations?: (parentId: string | null) => string[];
// Settings-specific properties
settingsData?: any; // Unified data object for settings fields
settingsFields?: Record<string, SettingsFieldConfig[]>; // Field definitions per sectionId
settingsLoading?: Record<string, boolean>; // Loading state per section
settingsErrors?: Record<string, string | null>; // Error state per section
saveSection?: (sectionId: string, data: any) => Promise<void>; // Save handler for a section
// Dropdown data source loading property
[key: string]: any; // Allow additional properties for dynamic data sources
}
// Standard action button configuration (built-in actions: edit, delete, view, copy, connect, play)
export interface ActionButtonConfig {
type: 'view' | 'edit' | 'delete' | 'copy' | 'connect' | 'play';
onAction?: (row: any) => Promise<void> | void; // Optional for delete buttons since they handle their own logic
title?: string | LanguageText;
disabled?: (row: any, hookData?: any) => boolean | { disabled: boolean; message?: string };
loading?: (row: any, hookData?: any) => boolean;
// Field mappings for flexible data access
idField?: string; // Field name for the unique identifier (default: 'id')
nameField?: string; // Field name for display name (default: 'name' or 'file_name')
typeField?: string; // Field name for type/mime type (default: 'type' or 'mime_type')
contentField?: string; // Field name for content (default: 'content')
statusField?: string; // Field name for status (used by connect action)
// Operation and loading state names
operationName?: string; // Name of the operation function in hookData
loadingStateName?: string; // Name of the loading state in hookData
fetchItemFunctionName?: string; // Name of the function in hookData to fetch a single item by ID (for edit button)
// Navigation and mode (for play action)
navigateTo?: string; // Path to navigate to after action
mode?: string; // Mode to set (e.g., 'prompt', 'workflow')
}
// Custom action button configuration (for entity-specific actions like download, connect, play, sendPasswordLink)
export interface CustomActionConfig {
id: string; // Unique identifier for the action
icon: React.ReactNode; // Icon component to display
onClick: (row: any, hookData?: any) => Promise<void> | void; // Handler function
visible?: (row: any, hookData?: any) => boolean; // Show/hide based on row data (default: true)
disabled?: (row: any, hookData?: any) => boolean | { disabled: boolean; message?: string }; // Disable based on row data
loading?: (row: any, hookData?: any) => boolean; // Loading state based on row data
title?: string | LanguageText | ((row: any) => string); // Tooltip text
className?: string; // Optional custom CSS class
// Field mappings (optional, for convenience)
idField?: string; // Field name for the unique identifier (default: 'id')
}
// Table content configuration
export interface TableContentConfig {
hookFactory: () => () => GenericDataHook; // Hook factory that returns a hook function
columns?: any[]; // Column configuration (optional - can be generated dynamically from attributes via hookData.columns)
actionButtons?: ActionButtonConfig[]; // Standard action buttons configuration (edit, delete, view, copy)
customActions?: CustomActionConfig[]; // Custom action buttons (download, connect, play, sendPasswordLink, etc.)
searchable?: boolean;
filterable?: boolean;
sortable?: boolean;
resizable?: boolean;
pagination?: boolean;
pageSize?: number;
className?: string;
emptyMessage?: string; // Custom message to display when table is empty
}
// Language-aware text interface
export interface LanguageText {
de: string;
en: string;
fr: string;
}
// Utility function to resolve language text
export const resolveLanguageText = (text: string | LanguageText | undefined, t?: (key: string, fallback?: string) => string): string => {
if (!text) return '';
if (typeof text === 'string') {
// Always use the translation function for strings (language keys)
if (t) {
return t(text);
}
return text;
}
// For LanguageText objects, we should convert them to language keys
// For now, fallback to the first available language
return text.de || text.en || text.fr || '';
};
// Generic page data interface
export interface GenericPageData {
// Core identification
id: string;
path: string;
name: string;
description?: string | LanguageText;
// Navigation
parentPath?: string; // For subpages/subsubpages
order?: number;
showInSidebar?: boolean;
// Visual
icon?: IconType;
title: string | LanguageText;
subtitle?: string | LanguageText;
// Header configuration
headerButtons?: PageButton[];
// Content sections
content?: PageContent[];
// Page behavior
persistent?: boolean;
preserveState?: boolean;
preload?: boolean;
moduleEnabled?: boolean;
hide?: boolean; // If true, page is completely hidden and not rendered
// Subpage support
hasSubpages?: boolean;
// Lifecycle hooks
onActivate?: () => void | Promise<void>;
onDeactivate?: () => void | Promise<void>;
onLoad?: () => void | Promise<void>;
onUnload?: () => void | Promise<void>;
// Custom component override (optional)
customComponent?: React.ComponentType<any>;
// Privilege checker - if provided, page will only render if checker returns true
privilegeChecker?: PrivilegeChecker;
// Drag and drop configuration
dragDropConfig?: DragDropConfig;
}
// Page data file structure
export interface PageDataFile {
page: GenericPageData;
subpages?: PageDataFile[];
}
// Sidebar item interface for compatibility
export interface SidebarItem {
id: string;
name: string;
link: string | undefined; // Allow undefined for parent groups that aren't clickable pages
icon?: IconType | React.ComponentType<React.SVGProps<SVGSVGElement>>; // Allow both IconType and SVG components
moduleEnabled: boolean;
order: number;
submenu?: SidebarSubmenuItemData[];
depth?: number; // Hierarchy depth for indentation (0 = top level)
}
// Sidebar submenu item data interface
export interface SidebarSubmenuItemData {
id: string;
name: string;
link?: string; // Optional - if undefined, it's a navigation node (not a page)
icon?: IconType | React.ComponentType<React.SVGProps<SVGSVGElement>>; // Allow both IconType and SVG components
submenu?: SidebarSubmenuItemData[]; // Recursive support for nested submenus
depth?: number; // Hierarchy depth for indentation (0 = top level)
}
// Page instance for PageManager
export interface PageInstance {
path: string;
component: React.ReactElement;
isActive: boolean;
shouldPreserve: boolean;
pageData: GenericPageData;
}
// Page manager props
export interface PageManagerProps {
loadingComponent: React.ComponentType;
errorComponent: React.ComponentType;
}

View file

@ -1,243 +0,0 @@
/**
* useAccessRules Hook
*
* Hook for managing RBAC access rules for a role.
* Supports both system admin (template roles) and feature admin (instance roles).
*/
import { useState, useCallback } from 'react';
import api from '../api';
// =============================================================================
// TYPES
// =============================================================================
export type RuleContext = 'DATA' | 'UI' | 'RESOURCE';
export type AccessLevel = 'n' | 'm' | 'g' | 'a' | null;
// =============================================================================
// ACCESS LEVEL LABELS
// =============================================================================
export const ACCESS_LEVEL_OPTIONS: { value: 'n' | 'm' | 'g' | 'a'; label: string; color: string }[] = [
{ value: 'n', label: 'Keine', color: '#e53e3e' },
{ value: 'm', label: 'Eigene', color: '#d69e2e' },
{ value: 'g', label: 'Gruppe', color: '#3182ce' },
{ value: 'a', label: 'Alle', color: '#38a169' },
];
export const getAccessLevelLabel = (level: AccessLevel | null): string => {
if (!level) return '-';
const option = ACCESS_LEVEL_OPTIONS.find(opt => opt.value === level);
return option?.label || level;
};
export const getAccessLevelColor = (level: AccessLevel | null): string => {
if (!level) return '#718096';
const option = ACCESS_LEVEL_OPTIONS.find(opt => opt.value === level);
return option?.color || '#718096';
};
export interface AccessRule {
id: string;
roleId: string;
context: RuleContext;
item: string | null;
view: boolean;
read: AccessLevel;
create: AccessLevel;
update: AccessLevel;
delete: AccessLevel;
}
export interface AccessRuleCreate {
context: RuleContext;
item?: string | null;
view?: boolean;
read?: AccessLevel;
create?: AccessLevel;
update?: AccessLevel;
delete?: AccessLevel;
}
interface GroupedRules {
DATA: AccessRule[];
UI: AccessRule[];
RESOURCE: AccessRule[];
}
interface SaveResult {
success: boolean;
error?: string;
}
// =============================================================================
// HOOK
// =============================================================================
export function useAccessRules(roleId: string, apiBasePath: string = '/api/rbac') {
const [rules, setRules] = useState<AccessRule[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Determine if this is a feature-instance API path
const isInstanceApi = apiBasePath.includes('/instance-roles/');
/**
* Fetch all rules for the role
*/
const fetchRules = useCallback(async (): Promise<AccessRule[]> => {
setLoading(true);
setError(null);
try {
// Different endpoint structure for instance roles vs system roles
const endpoint = isInstanceApi
? `${apiBasePath}/rules`
: `${apiBasePath}/rules/by-role/${roleId}`;
const response = await api.get(endpoint);
const fetchedRules = response.data?.items || response.data || [];
setRules(fetchedRules);
return fetchedRules;
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Laden der Regeln';
setError(errorMsg);
console.error('Error fetching rules:', err);
return [];
} finally {
setLoading(false);
}
}, [roleId, apiBasePath, isInstanceApi]);
/**
* Save all rules for the role
*/
const saveRules = useCallback(async (rulesToSave: AccessRule[]): Promise<SaveResult> => {
setSaving(true);
setError(null);
try {
// Different endpoint structure for instance roles vs system roles
const rulesEndpoint = isInstanceApi
? `${apiBasePath}/rules`
: `${apiBasePath}/rules/by-role/${roleId}`;
// Get current rules from server
const currentResponse = await api.get(rulesEndpoint);
const currentRules: AccessRule[] = currentResponse.data?.items || currentResponse.data || [];
const currentRuleIds = new Set(currentRules.map(r => r.id));
// Determine changes
const newRules = rulesToSave.filter(r => r.id.startsWith('temp-'));
const existingRules = rulesToSave.filter(r => !r.id.startsWith('temp-'));
const deletedRuleIds = [...currentRuleIds].filter(
id => !existingRules.some(r => r.id === id)
);
// Delete removed rules
for (const deletedId of deletedRuleIds) {
const deleteEndpoint = isInstanceApi
? `${apiBasePath}/rules/${deletedId}`
: `${apiBasePath}/rules/${deletedId}`;
await api.delete(deleteEndpoint);
}
// Create new rules
for (const rule of newRules) {
const createEndpoint = isInstanceApi
? `${apiBasePath}/rules`
: `${apiBasePath}/rules`;
await api.post(createEndpoint, {
roleId,
context: rule.context,
item: rule.item,
view: rule.view,
read: rule.read,
create: rule.create,
update: rule.update,
delete: rule.delete,
});
}
// Update existing rules
for (const rule of existingRules) {
const original = currentRules.find(r => r.id === rule.id);
if (original && JSON.stringify(original) !== JSON.stringify(rule)) {
const updateEndpoint = isInstanceApi
? `${apiBasePath}/rules/${rule.id}`
: `${apiBasePath}/rules/${rule.id}`;
await api.put(updateEndpoint, {
view: rule.view,
read: rule.read,
create: rule.create,
update: rule.update,
delete: rule.delete,
});
}
}
// Refresh rules
await fetchRules();
return { success: true };
} catch (err: any) {
const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Speichern';
setError(errorMsg);
console.error('Error saving rules:', err);
return { success: false, error: errorMsg };
} finally {
setSaving(false);
}
}, [roleId, apiBasePath, isInstanceApi, fetchRules]);
/**
* Get rules grouped by context
*/
const getGroupedRules = useCallback((): GroupedRules => {
return {
DATA: rules.filter(r => r.context === 'DATA'),
UI: rules.filter(r => r.context === 'UI'),
RESOURCE: rules.filter(r => r.context === 'RESOURCE'),
};
}, [rules]);
/**
* Update a rule locally (not saved until saveRules is called)
*/
const updateRuleLocally = useCallback((ruleId: string, updates: Partial<AccessRule>) => {
setRules(prev => prev.map(r =>
r.id === ruleId ? { ...r, ...updates } : r
));
}, []);
/**
* Add a rule locally (not saved until saveRules is called)
*/
const addRuleLocally = useCallback((rule: AccessRule) => {
setRules(prev => [...prev, rule]);
}, []);
/**
* Remove a rule locally (not saved until saveRules is called)
*/
const removeRuleLocally = useCallback((ruleId: string) => {
setRules(prev => prev.filter(r => r.id !== ruleId));
}, []);
return {
rules,
loading,
saving,
error,
fetchRules,
saveRules,
getGroupedRules,
updateRuleLocally,
addRuleLocally,
removeRuleLocally,
};
}
export default useAccessRules;

View file

@ -1,771 +0,0 @@
import { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { useApiRequest } from './useApi';
import api from '../api';
import {
startChatbotStreamApi,
stopChatbotApi,
getChatbotThreadsApi,
getChatbotThreadApi,
deleteChatbotWorkflowApi,
type ChatDataItem,
type StartChatbotRequest,
type ChatbotWorkflow
} from '../api/chatbotApi';
import { Message } from '../components/UiComponents/Messages/MessagesTypes';
// Simple sort function for messages
const sortMessages = (a: Message, b: Message) => {
if (a.publishedAt !== undefined && b.publishedAt !== undefined) {
return a.publishedAt - b.publishedAt;
}
if (a.publishedAt !== undefined) return -1;
if (b.publishedAt !== undefined) return 1;
if (a.sequenceNr !== undefined && b.sequenceNr !== undefined) {
return a.sequenceNr - b.sequenceNr;
}
return 0;
};
export function useChatbot() {
const [inputValue, setInputValue] = useState<string>('');
const [messages, setMessages] = useState<Message[]>([]);
const [workflowId, setWorkflowId] = useState<string | null>(null);
const [isRunning, setIsRunning] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// File upload state
const [pendingFileIds, setPendingFileIds] = useState<string[]>([]);
const pendingFileIdsRef = useRef<string[]>([]); // Ref to avoid closure issues
const [uploadingFile, setUploadingFile] = useState<boolean>(false);
const [uploadError, setUploadError] = useState<string | null>(null);
const [uploadedFiles, setUploadedFiles] = useState<Array<{ fileId: string; fileName: string }>>([]);
// Chat history state
const [threads, setThreads] = useState<ChatbotWorkflow[]>([]);
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
const [threadsLoading, setThreadsLoading] = useState<boolean>(false);
const [threadsError, setThreadsError] = useState<string | null>(null);
const [deletingThreads, setDeletingThreads] = useState<Set<string>>(new Set());
const { request } = useApiRequest();
const streamAbortControllerRef = useRef<AbortController | null>(null);
const processedMessageIdsRef = useRef<Set<string>>(new Set());
const thinkingMessageIdRef = useRef<string | null>(null);
const thinkingLogsRef = useRef<string[]>([]); // Use ref instead of state to avoid batching
const logQueueRef = useRef<string[]>([]); // Queue for logs to process one by one
const isProcessingLogsRef = useRef<boolean>(false); // Flag to prevent concurrent processing
const processedLogsRef = useRef<Set<string>>(new Set()); // Track processed logs to prevent duplicates
// Clear processed message IDs when workflow changes
const clearProcessedMessages = useCallback(() => {
processedMessageIdsRef.current.clear();
}, []);
// Clear thinking message when a new assistant message arrives
const clearThinkingMessage = useCallback(() => {
// Clear log queue and stop processing
logQueueRef.current = [];
isProcessingLogsRef.current = false;
processedLogsRef.current.clear(); // Clear processed logs tracking
// Reset thinking message refs
thinkingMessageIdRef.current = null;
thinkingLogsRef.current = [];
// Remove ALL thinking messages (not just the one with current ID)
// This handles cases where multiple thinking messages might exist
setMessages(prevMessages => {
return prevMessages.filter(m => m.status !== 'thinking');
});
}, []);
// Process logs from queue one by one (progressive display)
const processLogQueue = useCallback(() => {
if (isProcessingLogsRef.current || logQueueRef.current.length === 0) {
return;
}
isProcessingLogsRef.current = true;
const logMessage = logQueueRef.current.shift()!;
// Add log to accumulated logs
thinkingLogsRef.current = [...thinkingLogsRef.current, logMessage];
// Get or create thinking message ID
const thinkingId = thinkingMessageIdRef.current || `thinking-${Date.now()}`;
thinkingMessageIdRef.current = thinkingId;
// Create/update thinking message with all accumulated logs
// Format logs as Markdown list items so each log appears on a separate line
const formattedLogs = thinkingLogsRef.current
.map(log => `- ${log.trim()}`)
.join('\n');
const thinkingMessage: Message = {
id: thinkingId,
workflowId: workflowId || '',
role: 'assistant',
message: formattedLogs,
publishedAt: Date.now() - 1,
status: 'thinking'
};
// Update messages immediately
setMessages(prevMessages => {
// Remove ALL thinking messages first (to prevent duplicates from previous workflows)
const filtered = prevMessages.filter(m => m.status !== 'thinking');
// Add updated thinking message
const updated = [...filtered, thinkingMessage];
return updated.sort(sortMessages);
});
// Process next log after a small delay (progressive display)
if (logQueueRef.current.length > 0) {
setTimeout(() => {
isProcessingLogsRef.current = false;
processLogQueue();
}, 50); // Small delay between logs for progressive display
} else {
isProcessingLogsRef.current = false;
}
}, [workflowId]);
// Add a single log to thinking message (progressive display)
const addLogToThinkingMessage = useCallback((logMessage: string, createdAt?: number) => {
// Create a unique key for this log message to detect duplicates
// Use content + createdAt timestamp if available, otherwise use current time
const timestamp = createdAt || Date.now();
const logKey = `${logMessage.trim()}_${timestamp}`;
// Skip if this log was already processed
if (processedLogsRef.current.has(logKey)) {
console.log('[useChatbot] Skipping duplicate log:', logMessage.substring(0, 50) + '...');
return;
}
// Mark as processed
processedLogsRef.current.add(logKey);
// Add log to queue
logQueueRef.current.push(logMessage);
// Start processing if not already processing
if (!isProcessingLogsRef.current) {
processLogQueue();
}
}, [processLogQueue]);
// Process SSE event and update messages
const processChatDataItem = useCallback((item: ChatDataItem) => {
// Log the actual streamed response for debugging
console.log('[useChatbot] Streamed item:', {
type: item.type,
createdAt: item.createdAt,
item: item.item
});
if (item.type === 'log' && item.item) {
// Process log item - add to thinking message one at a time
const logData = item.item as any;
const logMessage = logData.message || logData.text || '';
console.log('[useChatbot] Processing log:', {
message: logMessage.substring(0, 100) + (logMessage.length > 100 ? '...' : ''),
createdAt: item.createdAt,
fullItem: item
});
if (logMessage) {
// Add log immediately (progressive display) with createdAt for deduplication
addLogToThinkingMessage(logMessage, item.createdAt);
}
} else if (item.type === 'message' && item.item) {
const messageData = item.item as any;
// Ensure message has required fields
if (!messageData.id) {
console.warn('⚠️ Invalid message item (missing id):', messageData);
return;
}
// ALWAYS clear thinking message when ANY message arrives (not just assistant)
// The thinking message should disappear when the real message comes
// Check if we've already processed this message
const messageId = messageData.id;
// Always clear thinking messages when a real message arrives
clearThinkingMessage();
if (processedMessageIdsRef.current.has(messageId)) {
// Update existing message
setMessages(prevMessages => {
const existingIndex = prevMessages.findIndex(m => m.id === messageId);
if (existingIndex >= 0) {
const updated = [...prevMessages];
updated[existingIndex] = messageData as Message;
return updated.sort(sortMessages);
}
return prevMessages;
});
} else {
// Add new message
processedMessageIdsRef.current.add(messageId);
setMessages(prevMessages => {
// Add new message (thinking messages already cleared by clearThinkingMessage)
const updated = [...prevMessages, messageData as Message];
return updated.sort(sortMessages);
});
}
}
}, [addLogToThinkingMessage]);
// Load all threads (with loading state)
const loadThreads = useCallback(async () => {
try {
setThreadsLoading(true);
setThreadsError(null);
const result = await getChatbotThreadsApi(request);
// Sort threads by lastActivity (newest first)
const sortedThreads = [...result.items].sort((a, b) => {
const aTime = a.lastActivity || a.startedAt || 0;
const bTime = b.lastActivity || b.startedAt || 0;
return bTime - aTime; // Descending order (newest first)
});
setThreads(sortedThreads);
} catch (err: any) {
console.error('Error loading threads:', err);
setThreadsError(err.message || 'Failed to load threads');
} finally {
setThreadsLoading(false);
}
}, [request]);
// Load threads silently (without loading state) - keeps existing threads visible
const loadThreadsSilently = useCallback(async () => {
try {
setThreadsError(null);
const result = await getChatbotThreadsApi(request);
// Sort threads by lastActivity (newest first)
const sortedThreads = [...result.items].sort((a, b) => {
const aTime = a.lastActivity || a.startedAt || 0;
const bTime = b.lastActivity || b.startedAt || 0;
return bTime - aTime; // Descending order (newest first)
});
setThreads(sortedThreads);
} catch (err: any) {
console.error('Error loading threads silently:', err);
setThreadsError(err.message || 'Failed to load threads');
}
}, [request]);
// Load a specific workflow and its messages
const loadWorkflow = useCallback(async (workflowIdToLoad: string) => {
try {
setError(null);
const result = await getChatbotThreadApi(request, workflowIdToLoad);
console.log('[loadWorkflow] Full result:', JSON.stringify(result, null, 2));
console.log('[loadWorkflow] chatData structure:', {
isArray: Array.isArray(result.chatData),
isObject: result.chatData && typeof result.chatData === 'object',
chatDataKeys: result.chatData && typeof result.chatData === 'object' ? Object.keys(result.chatData) : [],
hasItems: result.chatData?.items !== undefined,
itemsLength: Array.isArray(result.chatData?.items) ? result.chatData.items.length : 'not an array'
});
// Convert chatData to Message[]
const loadedMessages: Message[] = [];
const processedIds = new Set<string>();
// Backend returns chatData as { items: ChatDataItem[] } structure
const chatDataArray: any[] = result.chatData?.items || [];
console.log('[loadWorkflow] Processing chatDataArray:', {
length: chatDataArray.length,
types: chatDataArray.map((item, idx) => ({
index: idx,
type: item?.type,
hasItem: !!item?.item,
keys: item ? Object.keys(item) : []
}))
});
for (const item of chatDataArray) {
// Handle ChatDataItem structure: { type: 'message', createdAt: number, item: Message }
if (item.type === 'message' && item.item) {
const messageData = item.item as any;
if (messageData.id && !processedIds.has(messageData.id)) {
processedIds.add(messageData.id);
loadedMessages.push(messageData as Message);
}
}
// Handle direct Message structure (if item is already a message)
else if (item.role && item.message !== undefined) {
// This looks like a Message object directly
if (item.id && !processedIds.has(item.id)) {
processedIds.add(item.id);
loadedMessages.push(item as Message);
}
}
}
console.log('[loadWorkflow] Loaded messages:', {
count: loadedMessages.length,
messageIds: loadedMessages.map(m => m.id),
sampleMessage: loadedMessages.length > 0 ? loadedMessages[0] : null
});
// Sort messages chronologically
const sortedMessages = loadedMessages.sort(sortMessages);
// Update state
setMessages(sortedMessages);
setWorkflowId(workflowIdToLoad);
processedMessageIdsRef.current = processedIds;
console.log('[loadWorkflow] State updated:', {
messagesCount: sortedMessages.length,
workflowId: workflowIdToLoad
});
// Don't refresh threads list here - it causes unwanted loading state
// Threads will be refreshed after new messages are sent/completed
} catch (err: any) {
console.error('Error loading workflow:', err);
setError(err.message || 'Failed to load workflow');
}
}, [request]);
// Select a thread (loads its messages)
const selectThread = useCallback(async (workflowIdToSelect: string) => {
setSelectedThreadId(workflowIdToSelect);
await loadWorkflow(workflowIdToSelect);
}, [loadWorkflow]);
// Auto-load threads on initialization
useEffect(() => {
loadThreads();
}, [loadThreads]);
// Handle input change
const onInputChange = useCallback((value: string) => {
setInputValue(value);
}, []);
// Handle file upload
const handleFileUpload = useCallback(async (file: File): Promise<{ success: boolean; data: any }> => {
setUploadError(null);
setUploadingFile(true);
try {
// Validate file before upload
if (!file || !file.name || file.name.trim() === '') {
throw new Error('Invalid file: File must have a valid name');
}
if (file.size === 0) {
throw new Error('Invalid file: File cannot be empty');
}
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/api/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
}
});
const fileData = response.data;
// Extract fileId from response
// Backend returns: { message: "...", file: { id: "...", ... }, duplicateType: "..." }
const fileId = fileData?.file?.id || fileData?.id || fileData?.fileId;
if (!fileId) {
console.error('Upload response structure:', fileData);
throw new Error('Upload failed: No file ID returned from server');
}
// Extract file name from response (use storedFileName if available, otherwise original fileName)
const fileName = fileData?.file?.fileName || fileData?.storedFileName || file.name;
// Add to pending file IDs and uploaded files list
setPendingFileIds(prev => {
const updated = [...prev, fileId];
pendingFileIdsRef.current = updated; // Keep ref in sync
return updated;
});
setUploadedFiles(prev => [...prev, { fileId, fileName }]);
return { success: true, data: fileData };
} catch (err: any) {
console.error('File upload failed:', err);
const errorMessage = err.message || 'Failed to upload file';
setUploadError(errorMessage);
return { success: false, data: null };
} finally {
setUploadingFile(false);
}
}, []);
// Handle file remove (remove from pending list)
const handleFileRemove = useCallback((fileId: string) => {
setPendingFileIds(prev => {
const updated = prev.filter(id => id !== fileId);
pendingFileIdsRef.current = updated; // Keep ref in sync
return updated;
});
setUploadedFiles(prev => prev.filter(f => f.fileId !== fileId));
}, []);
// Stop chatbot workflow
const stopChatbot = useCallback(async () => {
if (!workflowId || !isRunning) {
return;
}
try {
setIsSubmitting(true);
// Abort any ongoing stream
if (streamAbortControllerRef.current) {
streamAbortControllerRef.current.abort();
streamAbortControllerRef.current = null;
}
// Clear thinking message when stopping
clearThinkingMessage();
// Call stop API
await stopChatbotApi(request, workflowId);
setIsRunning(false);
setError(null);
} catch (err: any) {
console.error('Error stopping chatbot:', err);
setError(err.message || 'Failed to stop chatbot');
} finally {
setIsSubmitting(false);
}
}, [workflowId, isRunning, request, clearThinkingMessage]);
// Handle form submit
const handleSubmit = useCallback(async () => {
// If running, stop instead of starting
if (isRunning && workflowId) {
await stopChatbot();
return;
}
const trimmedInput = inputValue.trim();
if (!trimmedInput || isSubmitting) {
return;
}
try {
setIsSubmitting(true);
setError(null);
setIsRunning(true);
// Abort any existing stream
if (streamAbortControllerRef.current) {
streamAbortControllerRef.current.abort();
}
// Create new abort controller for this stream
const abortController = new AbortController();
streamAbortControllerRef.current = abortController;
// Use ref to get current file IDs (avoids closure issues)
const fileIdsToSend = pendingFileIdsRef.current.length > 0
? pendingFileIdsRef.current
: pendingFileIds; // Fallback to state if ref is empty
// Log for debugging
console.log('[handleSubmit] pendingFileIds from state:', pendingFileIds);
console.log('[handleSubmit] pendingFileIds from ref:', pendingFileIdsRef.current);
console.log('[handleSubmit] fileIdsToSend:', fileIdsToSend);
const requestBody: StartChatbotRequest = {
prompt: trimmedInput,
userLanguage: 'en',
...(workflowId && { workflowId })
};
// Always include listFileId if there are any files
if (fileIdsToSend.length > 0) {
requestBody.listFileId = fileIdsToSend;
console.log('[handleSubmit] Added listFileId to requestBody:', fileIdsToSend);
} else {
console.warn('[handleSubmit] No file IDs to send! Check if files were uploaded correctly.');
}
console.log('[handleSubmit] Final requestBody:', JSON.stringify(requestBody, null, 2));
// Clear thinking message when starting a new request
clearThinkingMessage();
processedLogsRef.current.clear(); // Clear processed logs for new request
// Track if this is the first event (to reset isSubmitting)
let firstEventReceived = false;
// Start SSE stream
await startChatbotStreamApi(
requestBody,
(item: ChatDataItem) => {
// Check if stream was aborted
if (abortController.signal.aborted) {
return;
}
// Reset isSubmitting after first event to enable stop button
if (!firstEventReceived) {
firstEventReceived = true;
setIsSubmitting(false);
}
// Process the chat data item
processChatDataItem(item);
// Extract workflow ID from message if available
if (item.type === 'message' && item.item) {
const messageData = item.item as any;
if (messageData.workflowId) {
if (!workflowId) {
// New workflow created - select it automatically
setWorkflowId(messageData.workflowId);
setSelectedThreadId(messageData.workflowId);
} else {
// Existing workflow - ensure it's selected
setSelectedThreadId(messageData.workflowId);
}
}
}
},
(err: Error) => {
// Only handle error if stream wasn't aborted
if (!abortController.signal.aborted) {
console.error('SSE stream error:', err);
setError(err.message || 'Stream error occurred');
setIsRunning(false);
// Reset isSubmitting if stream fails before first event
if (!firstEventReceived) {
setIsSubmitting(false);
}
// Clear thinking messages on error
clearThinkingMessage();
} else {
// Stream was aborted (stopped) - clear thinking messages
clearThinkingMessage();
}
},
() => {
// Stream completed
if (!abortController.signal.aborted) {
setIsRunning(false);
setInputValue(''); // Clear input on completion
// Clear pending file IDs after successful submission (files are now part of conversation)
setPendingFileIds([]);
pendingFileIdsRef.current = []; // Clear ref too
setUploadedFiles([]);
// Clear thinking message on completion (final message should have cleared it, but ensure cleanup)
clearThinkingMessage();
// Refresh threads list after message completion (silently, without loading state)
setTimeout(() => {
loadThreadsSilently();
}, 100);
} else {
// Stream was aborted (stopped) - clear thinking messages
clearThinkingMessage();
}
}
);
// Clear input after starting (optimistic)
if (!workflowId) {
setInputValue('');
}
} catch (err: any) {
console.error('Error starting chatbot:', err);
setError(err.message || 'Failed to start chatbot');
setIsRunning(false);
// Clear thinking messages on error
clearThinkingMessage();
} finally {
setIsSubmitting(false);
streamAbortControllerRef.current = null;
}
}, [inputValue, workflowId, isRunning, isSubmitting, stopChatbot, processChatDataItem, clearThinkingMessage, loadThreads, pendingFileIds]);
// Delete a chatbot workflow
const handleDeleteThread = useCallback(async (workflowIdToDelete: string): Promise<boolean> => {
try {
// Add to deleting set
setDeletingThreads(prev => new Set(prev).add(workflowIdToDelete));
// If deleting the selected thread, clear selection and messages
if (selectedThreadId === workflowIdToDelete) {
setMessages([]);
setWorkflowId(null);
setSelectedThreadId(null);
}
// Call delete API
await deleteChatbotWorkflowApi(request, workflowIdToDelete);
// Remove from threads list optimistically
setThreads(prev => prev.filter(t => t.id !== workflowIdToDelete));
return true;
} catch (err: any) {
console.error('Error deleting thread:', err);
throw err;
} finally {
// Remove from deleting set
setDeletingThreads(prev => {
const next = new Set(prev);
next.delete(workflowIdToDelete);
return next;
});
}
}, [request, selectedThreadId]);
// Optimistically remove thread from list
const removeThreadOptimistically = useCallback((workflowId: string) => {
setThreads(prev => prev.filter(t => t.id !== workflowId));
// If deleting the selected thread, clear selection
if (selectedThreadId === workflowId) {
setSelectedThreadId(null);
setMessages([]);
setWorkflowId(null);
}
}, [selectedThreadId]);
// Start a new chat (clears selection and messages)
const startNewChat = useCallback(() => {
// Abort any ongoing stream
if (streamAbortControllerRef.current) {
streamAbortControllerRef.current.abort();
streamAbortControllerRef.current = null;
}
// Clear messages and selection
setMessages([]);
setWorkflowId(null);
setSelectedThreadId(null);
setError(null);
setInputValue('');
setPendingFileIds([]);
pendingFileIdsRef.current = [];
setUploadedFiles([]);
thinkingLogsRef.current = [];
thinkingMessageIdRef.current = null;
processedLogsRef.current.clear();
clearProcessedMessages();
}, [clearProcessedMessages]);
// Reset chatbot state
const resetChatbot = useCallback(() => {
// Abort any ongoing stream
if (streamAbortControllerRef.current) {
streamAbortControllerRef.current.abort();
streamAbortControllerRef.current = null;
}
setMessages([]);
setWorkflowId(null);
setSelectedThreadId(null);
setIsRunning(false);
setIsSubmitting(false);
setError(null);
setInputValue('');
setPendingFileIds([]);
pendingFileIdsRef.current = [];
setUploadedFiles([]);
thinkingLogsRef.current = [];
thinkingMessageIdRef.current = null;
processedLogsRef.current.clear();
clearProcessedMessages();
}, [clearProcessedMessages]);
// Cleanup on unmount
const cleanup = useCallback(() => {
if (streamAbortControllerRef.current) {
streamAbortControllerRef.current.abort();
streamAbortControllerRef.current = null;
}
logQueueRef.current = [];
isProcessingLogsRef.current = false;
processedLogsRef.current.clear();
}, []);
// Memoized display messages
const displayMessages = useMemo(() => {
return messages.sort(sortMessages);
}, [messages]);
return {
// GenericDataHook interface
data: [],
loading: false,
error,
// Input form interface
inputValue,
onInputChange,
handleSubmit,
isSubmitting,
// Workflow state
workflowId: workflowId || undefined,
isRunning,
// Messages
messages: displayMessages,
// Chat history state
threads,
selectedThreadId,
threadsLoading,
threadsError,
// Chat history methods
selectThread,
loadThreads,
// Delete methods
handleDelete: handleDeleteThread,
removeOptimistically: removeThreadOptimistically,
deletingItems: deletingThreads,
refetch: loadThreads,
// Additional methods
stopChatbot,
resetChatbot,
startNewChat,
cleanup,
// File upload interface
handleFileUpload,
handleUpload: handleFileUpload, // Alias for compatibility with DragDropOverlay
handleFileRemove,
pendingFileIds,
uploadedFiles,
uploadingFile,
uploadError
};
}
export function createChatbotHook() {
return () => useChatbot();
}

View file

@ -1,991 +0,0 @@
import { useState, useCallback, useEffect } from 'react';
import api from '../api';
import type { MapPoint, ParcelGeometry } from '../components/UiComponents/MapView';
import { wgs84ToLV95 } from '../components/UiComponents/MapView/LV95Converter';
// Parcel search response interfaces
export interface ParcelSearchResponse {
parcel: {
id: string;
egrid?: string;
number?: string;
name?: string;
identnd?: string;
canton?: string;
municipality_code?: number;
municipality_name?: string;
address?: string;
perimeter?: {
closed: boolean;
punkte: Array<{
koordinatensystem: string;
x: number;
y: number;
z: number | null;
}>;
};
area_m2?: number;
centroid?: { x: number; y: number };
geoportal_url?: string;
realestate_type?: string | null;
bauzone?: string | null;
zone?: Array<any> | null;
};
map_view: {
center: { x: number; y: number };
zoom_bounds: {
min_x: number;
min_y: number;
max_x: number;
max_y: number;
};
geometry_geojson: {
type: string;
geometry: {
type: string;
coordinates: number[][][];
};
properties: {
id: string;
egrid?: string;
number?: string;
};
};
};
adjacent_parcels?: Array<{
id: string;
egrid?: string;
number?: string;
perimeter?: {
closed: boolean;
punkte: Array<{
koordinatensystem: string;
x: number;
y: number;
z: number | null;
}>;
};
geometry_geojson?: {
type: string;
geometry: {
type: string;
coordinates: number[][][];
};
properties: {
id: string;
egrid?: string;
number?: string;
};
};
}>;
}
// Command response interface
export interface CommandResponse {
success: boolean;
intent?: string;
entity?: string;
result?: any;
error?: string;
}
// Project interfaces
export interface Projekt {
id: string;
mandateId?: string;
label: string;
statusProzess?: string;
perimeter?: any;
baulinie?: any;
parzellen?: any[];
dokumente?: any[];
kontextInformationen?: any[];
}
export interface CreateProjektResponse {
projekt: Projekt;
parzellen?: any[];
}
export interface AddParcelResponse {
projekt: Projekt;
parzelle: any;
}
// Main PEK hook
export function usePek() {
// Location input state - separate fields
const [kanton, setKanton] = useState<string>('');
const [gemeinde, setGemeinde] = useState<string>('');
const [adresse, setAdresse] = useState<string>('');
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [locationError, setLocationError] = useState<string | null>(null);
// Legacy locationInput for backward compatibility (combines fields)
const locationInput = [kanton, gemeinde, adresse].filter(Boolean).join(', ');
const setLocationInput = (value: string) => {
// Parse combined input if needed (for map clicks, etc.)
const parts = value.split(',').map(p => p.trim());
if (parts.length >= 3) {
setKanton(parts[0]);
setGemeinde(parts[1]);
setAdresse(parts.slice(2).join(', '));
} else {
setAdresse(value);
}
};
// Parcel search state
const [selectedParcels, setSelectedParcels] = useState<ParcelSearchResponse[]>([]);
const [isSearchingParcel, setIsSearchingParcel] = useState(false);
const [parcelSearchError, setParcelSearchError] = useState<string | null>(null);
// Map view state
const [mapCenter, setMapCenter] = useState<MapPoint | null>(null);
const [mapZoomBounds, setMapZoomBounds] = useState<{
min_x: number;
min_y: number;
max_x: number;
max_y: number;
} | null>(null);
const [parcelGeometries, setParcelGeometries] = useState<ParcelGeometry[]>([]);
// Command processing state
const [commandInput, setCommandInput] = useState<string>('');
const [isProcessingCommand, setIsProcessingCommand] = useState(false);
const [commandResults, setCommandResults] = useState<any[]>([]);
const [commandError, setCommandError] = useState<string | null>(null);
// Project state
const [currentProjekt, setCurrentProjekt] = useState<Projekt | null>(null);
const [isCreatingProjekt, setIsCreatingProjekt] = useState(false);
const [isAddingParcel, setIsAddingParcel] = useState(false);
const [projektError, setProjektError] = useState<string | null>(null);
// Panel state
const [isPanelOpen, setIsPanelOpen] = useState(false);
// Update parcel geometries when selected parcels change
// Ensure all selected parcels are marked as selected and not as adjacent
useEffect(() => {
const selectedParcelIds = new Set(selectedParcels.map(p => p.parcel.id));
setParcelGeometries(prev => prev.map(geo => {
const isSelected = selectedParcelIds.has(geo.id);
// If parcel is selected, it should not be marked as adjacent
const isAdjacent = isSelected ? false : geo.isAdjacent;
return { ...geo, isSelected, isAdjacent };
}));
}, [selectedParcels]);
/**
* Get current geolocation and directly search for parcel
* Does not fill input fields, directly makes the request
*/
const useCurrentLocation = useCallback(async () => {
setIsGettingLocation(true);
setLocationError(null);
try {
if (!navigator.geolocation) {
throw new Error('Geolocation wird von Ihrem Browser nicht unterstützt');
}
return new Promise<void>((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
async (position) => {
try {
// Convert WGS84 to LV95 using the converter function
const lat = position.coords.latitude;
const lon = position.coords.longitude;
const lv95 = wgs84ToLV95(lat, lon);
const locationString = `${Math.round(lv95.x)},${Math.round(lv95.y)}`;
// Directly search for parcel without updating input fields
await searchParcel(locationString, true);
resolve();
} catch (err: any) {
setLocationError(err.message || 'Fehler beim Konvertieren der Koordinaten');
reject(err);
}
},
(error) => {
let errorMessage = 'Fehler beim Abrufen der Position';
switch (error.code) {
case error.PERMISSION_DENIED:
errorMessage = 'Zugriff auf Standort wurde verweigert';
break;
case error.POSITION_UNAVAILABLE:
errorMessage = 'Standortinformationen nicht verfügbar';
break;
case error.TIMEOUT:
errorMessage = 'Zeitüberschreitung beim Abrufen der Position';
break;
}
setLocationError(errorMessage);
reject(new Error(errorMessage));
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
}
);
});
} catch (err: any) {
setLocationError(err.message || 'Fehler beim Abrufen der aktuellen Position');
throw err;
} finally {
setIsGettingLocation(false);
}
}, []);
/**
* Search for parcel by location (address or coordinates)
* Always includes adjacent parcels by default
*/
const searchParcel = useCallback(async (location: string, includeAdjacent: boolean = true) => {
if (!location.trim()) {
setParcelSearchError('Bitte geben Sie einen Standort ein');
return;
}
setIsSearchingParcel(true);
setParcelSearchError(null);
try {
const response = await api.get('/api/realestate/parcel/search', {
params: {
location: location.trim(),
include_adjacent: includeAdjacent
}
});
const data: ParcelSearchResponse = response.data;
// Debug logging
if (import.meta.env.DEV) {
console.log('📦 Parcel search response:', {
hasMapView: !!data.map_view,
hasGeometry: !!data.map_view?.geometry_geojson,
hasPerimeter: !!data.parcel.perimeter,
adjacentCount: data.adjacent_parcels?.length || 0
});
}
// Add parcel to selected parcels array if not already selected
// Update geometries within the callback to have access to updated selectedParcels
setSelectedParcels(prev => {
const exists = prev.some(p => p.parcel.id === data.parcel.id);
if (exists) {
return prev; // Already selected, don't add again
}
const updatedSelectedParcels = [...prev, data];
const selectedParcelIds = new Set(updatedSelectedParcels.map(p => p.parcel.id));
// Update geometries
setParcelGeometries(currentGeometries => {
const geometryMap = new Map<string, ParcelGeometry>();
// Keep existing geometries
currentGeometries.forEach(geo => {
geometryMap.set(geo.id, geo);
});
// Update map center and zoom bounds
if (data.map_view) {
setMapCenter(data.map_view.center);
setMapZoomBounds(data.map_view.zoom_bounds);
// Main parcel - use geometry_geojson if available, otherwise use perimeter.punkte
let mainParcelCoordinates: MapPoint[] = [];
if (data.map_view.geometry_geojson?.geometry?.coordinates) {
const coords = data.map_view.geometry_geojson.geometry.coordinates[0];
if (Array.isArray(coords)) {
mainParcelCoordinates = coords.map((coord: number[]) => ({
x: coord[0],
y: coord[1]
}));
}
} else if (data.parcel.perimeter?.punkte) {
mainParcelCoordinates = data.parcel.perimeter.punkte.map((p) => ({
x: p.x,
y: p.y
}));
}
if (mainParcelCoordinates.length > 0) {
geometryMap.set(data.parcel.id, {
id: data.parcel.id,
egrid: data.parcel.egrid,
number: data.parcel.number,
coordinates: mainParcelCoordinates,
isSelected: true,
isAdjacent: false
});
}
// Add adjacent parcels, but skip if already selected
if (data.adjacent_parcels && includeAdjacent && data.adjacent_parcels.length > 0) {
data.adjacent_parcels.forEach((adjacent) => {
// Skip if this adjacent parcel is already selected
if (selectedParcelIds.has(adjacent.id)) {
// If it exists, mark as selected, not adjacent
const existingGeo = geometryMap.get(adjacent.id);
if (existingGeo) {
geometryMap.set(adjacent.id, {
...existingGeo,
isSelected: true,
isAdjacent: false
});
}
if (import.meta.env.DEV) {
console.log(`⏭️ Skipping adjacent parcel ${adjacent.id} - already selected`);
}
return;
}
// Only add if not already in map
if (!geometryMap.has(adjacent.id)) {
let adjCoordinates: MapPoint[] = [];
if (adjacent.geometry_geojson?.geometry?.coordinates) {
const coords = adjacent.geometry_geojson.geometry.coordinates[0];
if (Array.isArray(coords) && coords.length > 0) {
adjCoordinates = coords.map((coord: number[]) => ({
x: coord[0],
y: coord[1]
}));
}
} else if (adjacent.perimeter?.punkte) {
adjCoordinates = adjacent.perimeter.punkte.map((p) => ({
x: p.x,
y: p.y
}));
}
if (adjCoordinates.length >= 3) {
geometryMap.set(adjacent.id, {
id: adjacent.id,
egrid: adjacent.egrid,
number: adjacent.number,
coordinates: adjCoordinates,
isSelected: false,
isAdjacent: true
});
}
}
});
}
} else {
// If no map_view, still try to use parcel data
if (data.parcel.perimeter?.punkte) {
const coordinates = data.parcel.perimeter.punkte.map((p) => ({
x: p.x,
y: p.y
}));
geometryMap.set(data.parcel.id, {
id: data.parcel.id,
egrid: data.parcel.egrid,
number: data.parcel.number,
coordinates,
isSelected: true,
isAdjacent: false
});
if (data.parcel.centroid) {
setMapCenter(data.parcel.centroid);
}
}
}
// Update all geometries: mark selected ones and unmark adjacent for selected ones
const updatedGeometries = Array.from(geometryMap.values()).map(geo => {
const isSelected = selectedParcelIds.has(geo.id);
return {
...geo,
isSelected,
isAdjacent: isSelected ? false : geo.isAdjacent
};
});
if (import.meta.env.DEV) {
console.log(`🗺️ Total geometries to display: ${updatedGeometries.length}`, {
selected: updatedGeometries.filter(g => g.isSelected).length,
adjacent: updatedGeometries.filter(g => g.isAdjacent).length
});
}
return updatedGeometries;
});
return updatedSelectedParcels;
});
// Open panel when parcel is found
setIsPanelOpen(true);
return { success: true, data };
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Fehler beim Suchen der Parzelle';
setParcelSearchError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setIsSearchingParcel(false);
}
}, []);
/**
* Handle map click - search for parcel at clicked coordinates
*/
const handleMapClick = useCallback(
async (point: MapPoint) => {
const locationString = `${point.x},${point.y}`;
setLocationInput(locationString);
await searchParcel(locationString, true); // Always include adjacent parcels
},
[searchParcel]
);
/**
* Check if a parcel is selected
*/
const isParcelSelected = useCallback((parcelId: string): boolean => {
return selectedParcels.some(p => p.parcel.id === parcelId);
}, [selectedParcels]);
/**
* Remove a parcel from selection
*/
const removeParcel = useCallback((parcelId: string) => {
setSelectedParcels(prev => prev.filter(p => p.parcel.id !== parcelId));
// Update geometries to reflect deselection
setParcelGeometries(prev => prev.map(geo =>
geo.id === parcelId ? { ...geo, isSelected: false } : geo
));
}, []);
/**
* Clear all selected parcels
*/
const clearSelectedParcels = useCallback(() => {
setSelectedParcels([]);
// Update geometries to reflect deselection
setParcelGeometries(prev => prev.map(geo => ({ ...geo, isSelected: false })));
}, []);
/**
* Handle parcel click on map - toggle parcel selection
*/
const handleParcelClick = useCallback(async (parcelId: string) => {
// Check if parcel is already selected
const isSelected = isParcelSelected(parcelId);
if (isSelected) {
// Remove from selection
removeParcel(parcelId);
} else {
// Find the clicked parcel in the geometries
const clickedParcel = parcelGeometries.find(p => p.id === parcelId);
if (clickedParcel && clickedParcel.coordinates.length > 0) {
// Use a point inside the parcel (first coordinate is always on the boundary, which is inside)
const firstCoord = clickedParcel.coordinates[0];
// Use first coordinate (guaranteed to be on/in the parcel) for search
const locationString = `${firstCoord.x},${firstCoord.y}`;
await searchParcel(locationString, true); // Always include adjacent parcels
} else {
// Fallback: try to search by parcel ID/EGRID if available
// Check all selected parcels for adjacent parcels
for (const selectedParcel of selectedParcels) {
if (selectedParcel.adjacent_parcels) {
const adjacentParcel = selectedParcel.adjacent_parcels.find(p => p.id === parcelId);
if (adjacentParcel?.egrid) {
// Search by EGRID
await searchParcel(adjacentParcel.egrid, true);
break;
} else if (adjacentParcel?.number) {
// Try searching by number (might need address context)
await searchParcel(adjacentParcel.number, true);
break;
} else if (adjacentParcel?.id) {
// Last resort: try searching by ID
await searchParcel(adjacentParcel.id, true);
break;
}
}
}
}
}
}, [parcelGeometries, selectedParcels, isParcelSelected, removeParcel, searchParcel]);
/**
* Process natural language command
* Always includes the currently selected parcel if available
*/
const processCommand = useCallback(async (userInput: string) => {
if (!userInput.trim()) {
setCommandError('Bitte geben Sie einen Befehl ein');
return;
}
setIsProcessingCommand(true);
setCommandError(null);
// Add user message
const userMessage = {
id: `user-${Date.now()}`,
role: 'user',
message: userInput.trim(),
timestamp: Date.now()
};
setCommandResults((prev) => [...prev, userMessage]);
try {
// Build request body with user input and selected parcel
const requestBody: any = {
userInput: userInput.trim()
};
// Always include the currently selected parcels if available
if (selectedParcels.length > 0) {
// Use first selected parcel for backward compatibility
const firstParcel = selectedParcels[0];
requestBody.selectedParcel = {
id: firstParcel.parcel.id,
egrid: firstParcel.parcel.egrid,
number: firstParcel.parcel.number,
name: firstParcel.parcel.name,
identnd: firstParcel.parcel.identnd,
canton: firstParcel.parcel.canton,
municipality_code: firstParcel.parcel.municipality_code,
municipality_name: firstParcel.parcel.municipality_name,
address: firstParcel.parcel.address,
area_m2: firstParcel.parcel.area_m2,
centroid: firstParcel.parcel.centroid,
geoportal_url: firstParcel.parcel.geoportal_url,
realestate_type: firstParcel.parcel.realestate_type,
bauzone: firstParcel.parcel.bauzone,
zone: firstParcel.parcel.zone,
// Include geometry data if available
geometry_geojson: firstParcel.map_view?.geometry_geojson,
perimeter: firstParcel.parcel.perimeter
};
// Also include all selected parcels as array
requestBody.selectedParcels = selectedParcels.map(p => ({
id: p.parcel.id,
egrid: p.parcel.egrid,
number: p.parcel.number,
name: p.parcel.name,
identnd: p.parcel.identnd,
canton: p.parcel.canton,
municipality_code: p.parcel.municipality_code,
municipality_name: p.parcel.municipality_name,
address: p.parcel.address,
area_m2: p.parcel.area_m2,
centroid: p.parcel.centroid,
geoportal_url: p.parcel.geoportal_url,
realestate_type: p.parcel.realestate_type,
bauzone: p.parcel.bauzone,
zone: p.parcel.zone,
geometry_geojson: p.map_view?.geometry_geojson,
perimeter: p.parcel.perimeter
}));
}
const response = await api.post('/api/realestate/command', requestBody);
const data: CommandResponse = response.data;
// Format response as assistant message
let responseMessage = '';
if (data.success) {
if (data.result) {
if (typeof data.result === 'object') {
responseMessage = `**Intent:** ${data.intent || 'Unknown'}\n**Entity:** ${data.entity || 'N/A'}\n\n**Result:**\n\`\`\`json\n${JSON.stringify(data.result, null, 2)}\n\`\`\``;
} else {
responseMessage = `**Intent:** ${data.intent || 'Unknown'}\n**Entity:** ${data.entity || 'N/A'}\n\n**Result:** ${data.result}`;
}
} else {
responseMessage = `Command executed successfully.\n**Intent:** ${data.intent || 'Unknown'}\n**Entity:** ${data.entity || 'N/A'}`;
}
} else {
responseMessage = `Error: ${data.error || 'Unknown error'}`;
}
const assistantMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',
message: responseMessage,
timestamp: Date.now()
};
setCommandResults((prev) => [...prev, assistantMessage]);
// If a project was created and there are selected parcels, automatically add them
if (data.success && data.intent === 'CREATE' && data.entity === 'Projekt' && selectedParcels.length > 0) {
try {
// Extract projekt from result
const projektResult = data.result?.result || data.result;
if (projektResult?.id) {
// Set as current projekt
setCurrentProjekt(projektResult);
// Add all selected parcels to the newly created project via direct API call
let addedCount = 0;
for (const selectedParcel of selectedParcels) {
try {
const addParcelRequestBody: any = {
parcelId: selectedParcel.parcel.id,
parcelData: {
id: selectedParcel.parcel.id,
egrid: selectedParcel.parcel.egrid,
number: selectedParcel.parcel.number,
name: selectedParcel.parcel.name,
identnd: selectedParcel.parcel.identnd,
canton: selectedParcel.parcel.canton,
municipality_code: selectedParcel.parcel.municipality_code,
municipality_name: selectedParcel.parcel.municipality_name,
address: selectedParcel.parcel.address,
area_m2: selectedParcel.parcel.area_m2,
centroid: selectedParcel.parcel.centroid,
geoportal_url: selectedParcel.parcel.geoportal_url,
realestate_type: selectedParcel.parcel.realestate_type,
bauzone: selectedParcel.parcel.bauzone,
zone: selectedParcel.parcel.zone,
geometry_geojson: selectedParcel.map_view?.geometry_geojson,
perimeter: selectedParcel.parcel.perimeter
}
};
const addResponse = await api.post(
`/api/realestate/projekt/${projektResult.id}/add-parcel`,
addParcelRequestBody
);
const addResult: AddParcelResponse = addResponse.data;
// Update current projekt with the updated version that includes the parcel
setCurrentProjekt(addResult.projekt);
addedCount++;
} catch (addError: any) {
console.error(`Failed to add parcel ${selectedParcel.parcel.id} to project:`, addError);
}
}
// Update the assistant message to indicate parcels were added
const parcelText = addedCount === 1 ? 'Parzelle' : 'Parzellen';
const updateMessage = {
...assistantMessage,
id: `assistant-update-${Date.now()}`,
message: `${responseMessage}\n\n✅ ${addedCount} ${parcelText} ${addedCount === 1 ? 'wurde' : 'wurden'} automatisch zum Projekt hinzugefügt.`
};
setCommandResults((prev) => {
const updated = [...prev];
const lastIndex = updated.length - 1;
if (updated[lastIndex]?.id === assistantMessage.id) {
updated[lastIndex] = updateMessage;
}
return updated;
});
}
} catch (addError: any) {
// Log error but don't fail the command
console.error('Failed to automatically add parcel to project:', addError);
const errorMessage = addError.response?.data?.detail || addError.message || 'Unbekannter Fehler';
const errorUpdate = {
id: `assistant-error-${Date.now()}`,
role: 'assistant',
message: `⚠️ Projekt wurde erstellt, aber Parzelle konnte nicht automatisch hinzugefügt werden: ${errorMessage}`,
timestamp: Date.now()
};
setCommandResults((prev) => [...prev, errorUpdate]);
}
}
// If a parcel was created and there are selected parcels, automatically populate it with the first selected parcel data
if (data.success && data.intent === 'CREATE' && data.entity === 'Parzelle' && selectedParcels.length > 0) {
const selectedParcel = selectedParcels[0]; // Use first selected parcel
try {
// Extract parzelle from result
const parzelleResult = data.result?.result || data.result;
if (parzelleResult?.id) {
// Update the newly created parcel with data from the selected parcel
const updateParcelRequestBody: any = {
// Map selected parcel data to parzelle fields
egrid: selectedParcel.parcel.egrid,
number: selectedParcel.parcel.number,
name: selectedParcel.parcel.name,
identnd: selectedParcel.parcel.identnd,
canton: selectedParcel.parcel.canton,
municipality_code: selectedParcel.parcel.municipality_code,
municipality_name: selectedParcel.parcel.municipality_name,
address: selectedParcel.parcel.address,
strasseNr: selectedParcel.parcel.address,
area_m2: selectedParcel.parcel.area_m2,
centroid: selectedParcel.parcel.centroid,
geoportal_url: selectedParcel.parcel.geoportal_url,
realestate_type: selectedParcel.parcel.realestate_type,
bauzone: selectedParcel.parcel.bauzone,
zone: selectedParcel.parcel.zone,
// Include geometry data
geometry_geojson: selectedParcel.map_view?.geometry_geojson,
perimeter: selectedParcel.parcel.perimeter
};
// Try to update the parcel via PUT request
try {
await api.put(
`/api/realestate/parzelle/${parzelleResult.id}`,
updateParcelRequestBody
);
// Update the assistant message to indicate parcel was populated
const updateMessage = {
...assistantMessage,
id: `assistant-update-${Date.now()}`,
message: `${responseMessage}\n\n✅ Parzelle wurde automatisch mit Daten der Kartenauswahl befüllt.`
};
setCommandResults((prev) => {
const updated = [...prev];
const lastIndex = updated.length - 1;
if (updated[lastIndex]?.id === assistantMessage.id) {
updated[lastIndex] = updateMessage;
}
return updated;
});
} catch (putError: any) {
// If PUT doesn't work, try PATCH
try {
await api.patch(
`/api/realestate/parzelle/${parzelleResult.id}`,
updateParcelRequestBody
);
const updateMessage = {
...assistantMessage,
id: `assistant-update-${Date.now()}`,
message: `${responseMessage}\n\n✅ Parzelle wurde automatisch mit Daten der Kartenauswahl befüllt.`
};
setCommandResults((prev) => {
const updated = [...prev];
const lastIndex = updated.length - 1;
if (updated[lastIndex]?.id === assistantMessage.id) {
updated[lastIndex] = updateMessage;
}
return updated;
});
} catch (patchError: any) {
// If both PUT and PATCH fail, log but don't fail the command
console.error('Failed to update parcel with selected parcel data:', patchError);
const errorMessage = patchError.response?.data?.detail || patchError.message || 'Unbekannter Fehler';
const errorUpdate = {
id: `assistant-error-${Date.now()}`,
role: 'assistant',
message: `⚠️ Parzelle wurde erstellt, aber konnte nicht automatisch mit Kartenauswahl-Daten befüllt werden: ${errorMessage}`,
timestamp: Date.now()
};
setCommandResults((prev) => [...prev, errorUpdate]);
}
}
}
} catch (updateError: any) {
// Log error but don't fail the command
console.error('Failed to automatically populate parcel with selected parcel data:', updateError);
const errorMessage = updateError.response?.data?.detail || updateError.message || 'Unbekannter Fehler';
const errorUpdate = {
id: `assistant-error-${Date.now()}`,
role: 'assistant',
message: `⚠️ Parzelle wurde erstellt, aber konnte nicht automatisch mit Kartenauswahl-Daten befüllt werden: ${errorMessage}`,
timestamp: Date.now()
};
setCommandResults((prev) => [...prev, errorUpdate]);
}
}
// Clear input on success
setCommandInput('');
return { success: true, data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to send command';
setCommandError(errorMessage);
// Add error message
const errorMsg = {
id: `error-${Date.now()}`,
role: 'assistant',
message: `**Error:** ${errorMessage}`,
timestamp: Date.now()
};
setCommandResults((prev) => [...prev, errorMsg]);
return { success: false, error: errorMessage };
} finally {
setIsProcessingCommand(false);
}
}, [selectedParcels]);
/**
* Create a new project
*/
const createProjekt = useCallback(
async (data: {
label: string;
statusProzess?: string;
location?: string;
parcelIds?: string[];
}) => {
setIsCreatingProjekt(true);
setProjektError(null);
try {
const requestBody: any = {
label: data.label
};
if (data.statusProzess) {
requestBody.statusProzess = data.statusProzess;
}
if (data.location) {
requestBody.location = data.location;
}
if (data.parcelIds && data.parcelIds.length > 0) {
requestBody.parcelIds = data.parcelIds;
}
const response = await api.post('/api/realestate/projekt/create', requestBody);
const result: CreateProjektResponse = response.data;
setCurrentProjekt(result.projekt);
return { success: true, data: result };
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Fehler beim Erstellen des Projekts';
setProjektError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setIsCreatingProjekt(false);
}
},
[]
);
/**
* Add a parcel to an existing project
*/
const addParcelToProjekt = useCallback(
async (
projektId: string,
data: {
parcelId?: string;
location?: string;
parcelData?: Record<string, any>;
}
) => {
if (!currentProjekt || currentProjekt.id !== projektId) {
setProjektError('Projekt nicht gefunden');
return { success: false, error: 'Projekt nicht gefunden' };
}
setIsAddingParcel(true);
setProjektError(null);
try {
const requestBody: any = {};
if (data.parcelId) {
requestBody.parcelId = data.parcelId;
} else if (data.location) {
requestBody.location = data.location;
} else if (data.parcelData) {
requestBody.parcelData = data.parcelData;
} else {
throw new Error('Bitte geben Sie parcelId, location oder parcelData an');
}
const response = await api.post(
`/api/realestate/projekt/${projektId}/add-parcel`,
requestBody
);
const result: AddParcelResponse = response.data;
setCurrentProjekt(result.projekt);
return { success: true, data: result };
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Fehler beim Hinzufügen der Parzelle';
setProjektError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setIsAddingParcel(false);
}
},
[currentProjekt]
);
// Build location string from separate fields
const buildLocationString = useCallback(() => {
const parts = [kanton, gemeinde, adresse].filter(Boolean);
return parts.join(', ');
}, [kanton, gemeinde, adresse]);
return {
// Location input - separate fields
kanton,
setKanton,
gemeinde,
setGemeinde,
adresse,
setAdresse,
buildLocationString,
// Legacy locationInput for backward compatibility
locationInput,
setLocationInput,
useCurrentLocation,
isGettingLocation,
locationError,
// Parcel search
selectedParcels,
searchParcel,
isSearchingParcel,
parcelSearchError,
removeParcel,
clearSelectedParcels,
isParcelSelected,
// Map view
mapCenter,
mapZoomBounds,
parcelGeometries,
handleMapClick,
handleParcelClick,
// Command processing
commandInput,
setCommandInput,
processCommand,
isProcessingCommand,
commandResults,
commandError,
// Project management
currentProjekt,
createProjekt,
isCreatingProjekt,
addParcelToProjekt,
isAddingParcel,
projektError,
// Panel state
isPanelOpen,
setIsPanelOpen
};
}

File diff suppressed because it is too large Load diff

View file

@ -1,237 +0,0 @@
/**
* useRoles Hook
*
* Hook für die Verwaltung von globalen RBAC-Rollen im Admin-Bereich.
* Folgt dem gleichen Pattern wie useOrgUsers.
*/
import { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from './useApi';
import api from '../api';
import { usePermissions, type UserPermissions } from './usePermissions';
import {
fetchRoles as fetchRolesApi,
fetchRoleById as fetchRoleByIdApi,
createRole as createRoleApi,
updateRole as updateRoleApi,
deleteRole as deleteRoleApi,
type Role,
type RoleUpdateData,
type PaginationParams
} from '../api/roleApi';
// Re-export types
export type { Role, RoleUpdateData, PaginationParams };
export interface AttributeDefinition {
name: string;
type: string;
label: string;
description?: string;
required?: boolean;
default?: any;
options?: Array<{ value: string | number; label: string | { [key: string]: string } }> | string;
sortable?: boolean;
filterable?: boolean;
searchable?: boolean;
width?: number;
minWidth?: number;
maxWidth?: number;
readonly?: boolean;
editable?: boolean;
}
/**
* Hook for managing RBAC roles in admin panel
*/
export function useAdminRoles() {
const [roles, setRoles] = useState<Role[]>([]);
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
const [pagination, setPagination] = useState<{
currentPage: number;
pageSize: number;
totalItems: number;
totalPages: number;
} | null>(null);
const { request, isLoading: loading, error } = useApiRequest<null, Role[]>();
const { checkPermission } = usePermissions();
// Fetch attributes from backend
const fetchAttributes = useCallback(async () => {
try {
const response = await api.get('/api/attributes/Role');
let attrs: AttributeDefinition[] = [];
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
attrs = response.data.attributes;
} else if (Array.isArray(response.data)) {
attrs = response.data;
} else if (response.data && typeof response.data === 'object') {
const keys = Object.keys(response.data);
for (const key of keys) {
if (Array.isArray(response.data[key])) {
attrs = response.data[key];
break;
}
}
}
setAttributes(attrs);
return attrs;
} catch (error: any) {
if (error.response?.status === 429) {
console.warn('Rate limit exceeded while fetching role attributes.');
} else if (error.response?.status !== 401) {
console.error('Error fetching role attributes:', error);
}
setAttributes([]);
return [];
}
}, []);
// Fetch permissions
const fetchPermissions = useCallback(async () => {
try {
const perms = await checkPermission('DATA', 'Role');
setPermissions(perms);
return perms;
} catch (error: any) {
console.error('Error fetching role permissions:', error);
const defaultPerms: UserPermissions = {
view: false,
read: 'n',
create: 'n',
update: 'n',
delete: 'n',
};
setPermissions(defaultPerms);
return defaultPerms;
}
}, [checkPermission]);
// Fetch roles
const fetchRoles = useCallback(async (params?: PaginationParams) => {
try {
const data = await fetchRolesApi(request, params);
if (data && typeof data === 'object' && 'items' in data) {
const items = Array.isArray(data.items) ? data.items : [];
setRoles(items);
if (data.pagination) {
setPagination(data.pagination);
}
} else {
const items = Array.isArray(data) ? data : [];
setRoles(items);
setPagination(null);
}
} catch (error: any) {
setRoles([]);
setPagination(null);
}
}, [request]);
// Optimistic updates
const removeOptimistically = (roleId: string) => {
setRoles(prev => prev.filter(r => r.id !== roleId));
};
const updateOptimistically = (roleId: string, updateData: Partial<Role>) => {
setRoles(prev =>
prev.map(r => r.id === roleId ? { ...r, ...updateData } : r)
);
};
// Fetch single role
const fetchRoleById = useCallback(async (roleId: string): Promise<Role | null> => {
return await fetchRoleByIdApi(request, roleId);
}, [request]);
// Generate columns from attributes (including fkSource/fkDisplayField for FK resolution)
const columns = attributes.map(attr => ({
key: attr.name,
label: attr.label || attr.name,
type: attr.type as any,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
fkSource: (attr as any).fkSource, // API endpoint for FK data
fkDisplayField: (attr as any).fkDisplayField, // Which field of FK target to display
}));
// Create role
const handleCreate = useCallback(async (roleData: Partial<Role>): Promise<boolean> => {
try {
await createRoleApi(request, roleData);
await fetchRoles();
return true;
} catch (error: any) {
console.error('Error creating role:', error);
return false;
}
}, [request, fetchRoles]);
// Update role
const handleUpdate = useCallback(async (roleId: string, updateData: RoleUpdateData): Promise<boolean> => {
try {
updateOptimistically(roleId, updateData);
await updateRoleApi(request, roleId, updateData);
return true;
} catch (error: any) {
console.error('Error updating role:', error);
await fetchRoles();
return false;
}
}, [request, fetchRoles]);
// Delete role
const handleDelete = useCallback(async (roleId: string): Promise<boolean> => {
try {
removeOptimistically(roleId);
await deleteRoleApi(request, roleId);
return true;
} catch (error: any) {
console.error('Error deleting role:', error);
await fetchRoles();
return false;
}
}, [request, fetchRoles]);
// Inline update
const handleInlineUpdate = useCallback(async (
roleId: string,
updateData: Partial<Role>
): Promise<void> => {
await handleUpdate(roleId, updateData);
}, [handleUpdate]);
// Load data on mount
useEffect(() => {
fetchAttributes();
fetchPermissions();
fetchRoles();
}, []);
return {
roles,
attributes,
columns,
permissions,
pagination,
loading,
error,
refetch: fetchRoles,
fetchRoleById,
handleCreate,
handleUpdate,
handleDelete,
handleInlineUpdate,
updateOptimistically,
};
}
export default useAdminRoles;

View file

@ -1,389 +0,0 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { useUser, useCurrentUser } from './useUsers';
import { useApiRequest } from './useApi';
import { GenericDataHook } from '../core/PageManager/pageInterface';
import type { SettingsFieldConfig } from '../core/PageManager/pageInterface';
// Interface for unified settings data
export interface SettingsData {
// User data (from API)
username?: string;
fullName?: string;
email?: string;
language?: string;
privilege?: string;
enabled?: boolean;
authenticationAuthority?: string;
// Phone name (localStorage)
phoneName?: string;
// Theme (localStorage)
theme?: string;
// Speech data (localStorage)
speechData?: any;
// Nested speech fields
mandate_general?: {
company_name?: string;
industry?: string;
contact_info?: {
email?: string;
phone?: string;
street?: string;
postal_code?: string;
city?: string;
country?: string;
};
business_hours?: string;
timezone?: string;
};
setup_contacts?: boolean;
}
// Create settings hook factory
export function createSettingsHook(): () => GenericDataHook {
return function useSettings() {
const { user: currentUser } = useCurrentUser();
const { getUser, updateUser } = useUser();
const { request } = useApiRequest();
const [settingsData, setSettingsData] = useState<SettingsData>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [settingsFields, setSettingsFields] = useState<Record<string, SettingsFieldConfig[]>>({});
const [settingsLoading, setSettingsLoading] = useState<Record<string, boolean>>({});
const [settingsErrors, setSettingsErrors] = useState<Record<string, string | null>>({});
// Track if we've loaded data initially to prevent infinite loops
const hasLoadedRef = useRef(false);
const currentUserIdRef = useRef<string | undefined>(currentUser?.id);
// Load phone name from localStorage
const _loadPhoneName = useCallback((): string => {
try {
return localStorage.getItem('userPhoneName') || '';
} catch (error) {
console.error('Failed to load phone name from localStorage:', error);
return '';
}
}, []);
void _loadPhoneName; // Intentionally unused, reserved for future use
// Load theme from localStorage
const _loadTheme = useCallback((): string => {
try {
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
return savedTheme;
}
// Default to system preference
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} catch (error) {
console.error('Failed to load theme from localStorage:', error);
return 'light';
}
}, []);
void _loadTheme; // Intentionally unused, reserved for future use
// Load speech data from localStorage
const _loadSpeechData = useCallback((): any | null => {
try {
const savedData = localStorage.getItem('speechSignUpData');
const timestamp = localStorage.getItem('speechSignUpTimestamp');
if (savedData && timestamp) {
const parsedData = JSON.parse(savedData);
const savedTime = parseInt(timestamp);
const now = Date.now();
const twentyFourHours = 24 * 60 * 60 * 1000;
// Check if data is still valid (within 24 hours)
if (now - savedTime < twentyFourHours) {
return parsedData;
} else {
// Data expired, clear it
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
return null;
}
}
return null;
} catch (error) {
console.error('Error loading speech data:', error);
return null;
}
}, []);
void _loadSpeechData; // Intentionally unused, reserved for future use
// Fetch user data from API
const _fetchUserData = useCallback(async () => {
if (!currentUser?.id) return null;
try {
const userData = await getUser(currentUser.id);
return userData;
} catch (error) {
console.error('Failed to fetch user data:', error);
throw error;
}
}, [currentUser?.id, getUser]);
void _fetchUserData; // Intentionally unused, reserved for future use
// Fetch field definitions from backend
const _fetchFieldsForSection = useCallback(async (sectionId: string): Promise<SettingsFieldConfig[]> => {
try {
setSettingsLoading(prev => ({ ...prev, [sectionId]: true }));
setSettingsErrors(prev => ({ ...prev, [sectionId]: null }));
// TODO: Replace with actual backend endpoint
// For now, return empty array - fields will come from backend later
const response = await request({
url: `/api/settings/fields?section=${sectionId}`,
method: 'get'
});
const fields = response?.fields || [];
setSettingsFields(prev => ({ ...prev, [sectionId]: fields }));
return fields;
} catch (error: any) {
const errorMessage = error.message || `Failed to load fields for ${sectionId}`;
console.error(`Error fetching fields for section ${sectionId}:`, error);
setSettingsErrors(prev => ({ ...prev, [sectionId]: errorMessage }));
return [];
} finally {
setSettingsLoading(prev => ({ ...prev, [sectionId]: false }));
}
}, [request]);
void _fetchFieldsForSection; // Intentionally unused, reserved for future use
// Load all settings data
const loadSettingsData = useCallback(async () => {
// Prevent multiple simultaneous loads
if (loading && hasLoadedRef.current) return;
try {
setLoading(true);
setError(null);
// Load from different sources - call functions directly to avoid dependency issues
const userData = currentUser?.id ? await getUser(currentUser.id) : null;
const phoneName = localStorage.getItem('userPhoneName') || '';
const savedTheme = localStorage.getItem('theme');
const theme = savedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
// Load speech data
let speechData: any | null = null;
try {
const savedData = localStorage.getItem('speechSignUpData');
const timestamp = localStorage.getItem('speechSignUpTimestamp');
if (savedData && timestamp) {
const parsedData = JSON.parse(savedData);
const savedTime = parseInt(timestamp);
const now = Date.now();
const twentyFourHours = 24 * 60 * 60 * 1000;
if (now - savedTime < twentyFourHours) {
speechData = parsedData;
} else {
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
}
}
} catch (error) {
console.error('Error loading speech data:', error);
}
// Merge all data into unified object
const unifiedData: SettingsData = {
...(userData || {}),
phoneName,
theme,
speechData,
// Flatten speech data if it exists
...(speechData?.mandate_general && {
mandate_general: speechData.mandate_general
}),
...(speechData?.setup_contacts !== undefined && {
setup_contacts: speechData.setup_contacts
})
};
setSettingsData(unifiedData);
hasLoadedRef.current = true;
} catch (error: any) {
console.error('Error loading settings data:', error);
setError(error.message || 'Failed to load settings');
} finally {
setLoading(false);
}
}, [currentUser?.id, getUser]); // Only depend on currentUser?.id and getUser
// Save section data
const saveSection = useCallback(async (sectionId: string, data: any) => {
try {
setSettingsLoading(prev => ({ ...prev, [sectionId]: true }));
setSettingsErrors(prev => ({ ...prev, [sectionId]: null }));
if (sectionId === 'user-info') {
// Separate user API data from localStorage data
const { phoneName, id, mandateId, ...userApiData } = data;
// Build clean update object with only allowed fields (explicitly exclude id and mandateId)
// Allowed fields: username, email, fullName, language, enabled, privilege, authenticationAuthority
const userUpdateData: Record<string, any> = {};
const allowedFields = ['username', 'email', 'fullName', 'language', 'enabled', 'privilege', 'authenticationAuthority'];
allowedFields.forEach(field => {
if (userApiData.hasOwnProperty(field)) {
userUpdateData[field] = userApiData[field];
}
});
// Save user data via API (only allowed fields, no id or mandateId)
if (currentUser?.id && Object.keys(userUpdateData).length > 0) {
const updatedUser = await updateUser(currentUser.id, userUpdateData);
// Update local state with API data
setSettingsData(prev => ({ ...prev, ...updatedUser }));
// Update localStorage cache
localStorage.setItem('currentUser', JSON.stringify(updatedUser));
// Dispatch event to notify other components
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
}
// Save phone name to localStorage separately
if (phoneName !== undefined) {
if (phoneName?.trim()) {
localStorage.setItem('userPhoneName', phoneName.trim());
} else {
localStorage.removeItem('userPhoneName');
}
// Update local state with phone name
setSettingsData(prev => ({ ...prev, phoneName }));
}
} else if (sectionId === 'theme') {
// Save theme to localStorage
localStorage.setItem('theme', data.theme);
// Apply theme immediately
document.documentElement.setAttribute('data-theme', data.theme);
document.documentElement.classList.remove('light-theme', 'dark-theme');
document.documentElement.classList.add(`${data.theme}-theme`);
// Update local state
setSettingsData(prev => ({ ...prev, theme: data.theme }));
} else if (sectionId === 'speech-settings') {
// Save speech data to localStorage
localStorage.setItem('speechSignUpData', JSON.stringify(data));
localStorage.setItem('speechSignUpTimestamp', Date.now().toString());
// Update local state
setSettingsData(prev => ({
...prev,
speechData: data,
...(data.mandate_general && { mandate_general: data.mandate_general }),
...(data.setup_contacts !== undefined && { setup_contacts: data.setup_contacts })
}));
// Dispatch event to notify other components
window.dispatchEvent(new CustomEvent('speechSignUpChanged'));
} else if (sectionId === 'phone-name') {
// Save phone name to localStorage
if (data.phoneName?.trim()) {
localStorage.setItem('userPhoneName', data.phoneName.trim());
} else {
localStorage.removeItem('userPhoneName');
}
// Update local state
setSettingsData(prev => ({ ...prev, phoneName: data.phoneName }));
}
} catch (error: any) {
const errorMessage = error.message || `Failed to save ${sectionId}`;
console.error(`Error saving section ${sectionId}:`, error);
setSettingsErrors(prev => ({ ...prev, [sectionId]: errorMessage }));
throw error;
} finally {
setSettingsLoading(prev => ({ ...prev, [sectionId]: false }));
}
}, [currentUser?.id, updateUser]);
// Initial load - only when currentUser?.id changes or on first mount
useEffect(() => {
// Only load if:
// 1. We haven't loaded yet, OR
// 2. The user ID changed
if (!hasLoadedRef.current || currentUserIdRef.current !== currentUser?.id) {
currentUserIdRef.current = currentUser?.id;
// Load data directly to avoid dependency issues
const loadData = async () => {
if (loading && hasLoadedRef.current) return;
try {
setLoading(true);
setError(null);
// Load from different sources
const userData = currentUser?.id ? await getUser(currentUser.id) : null;
const phoneName = localStorage.getItem('userPhoneName') || '';
const savedTheme = localStorage.getItem('theme');
const theme = savedTheme || (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
// Load speech data
let speechData: any | null = null;
try {
const savedData = localStorage.getItem('speechSignUpData');
const timestamp = localStorage.getItem('speechSignUpTimestamp');
if (savedData && timestamp) {
const parsedData = JSON.parse(savedData);
const savedTime = parseInt(timestamp);
const now = Date.now();
const twentyFourHours = 24 * 60 * 60 * 1000;
if (now - savedTime < twentyFourHours) {
speechData = parsedData;
} else {
localStorage.removeItem('speechSignUpData');
localStorage.removeItem('speechSignUpTimestamp');
}
}
} catch (error) {
console.error('Error loading speech data:', error);
}
// Merge all data into unified object
const unifiedData: SettingsData = {
...(userData || {}),
phoneName,
theme,
speechData,
...(speechData?.mandate_general && {
mandate_general: speechData.mandate_general
}),
...(speechData?.setup_contacts !== undefined && {
setup_contacts: speechData.setup_contacts
})
};
setSettingsData(unifiedData);
hasLoadedRef.current = true;
} catch (error: any) {
console.error('Error loading settings data:', error);
setError(error.message || 'Failed to load settings');
} finally {
setLoading(false);
}
};
loadData();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentUser?.id]); // Only depend on currentUser?.id
return {
data: [], // Not used for settings
loading,
error,
settingsData,
settingsFields,
settingsLoading,
settingsErrors,
saveSection,
refetch: loadSettingsData
} as GenericDataHook;
};
}

View file

@ -1,78 +0,0 @@
import { useState, useEffect } from 'react';
import { useSidebar as useNewSidebar } from '../core/PageManager/SidebarProvider';
import { SidebarItem } from '../core/PageManager/pageInterface';
import { useLanguage } from '../providers/language/LanguageContext';
// Hook to get sidebar items from page configurations
export const useSidebarFromPageConfigs = (): { items: SidebarItem[]; isLoading: boolean } => {
const { t } = useLanguage();
const [refreshTrigger, setRefreshTrigger] = useState(0);
// Use the new sidebar system
const { sidebarItems: newSidebarItems, loading: newLoading, refreshSidebar } = useNewSidebar();
// Listen for localStorage changes to refresh sidebar when sign-up status changes
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === 'speechSignUpData' || e.key === 'speechSignUpTimestamp') {
setRefreshTrigger(prev => prev + 1);
}
};
window.addEventListener('storage', handleStorageChange);
// Also listen for changes in the same tab
const handleCustomStorageChange = () => {
setRefreshTrigger(prev => prev + 1);
};
// Custom event for same-tab changes
window.addEventListener('speechSignUpChanged', handleCustomStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
window.removeEventListener('speechSignUpChanged', handleCustomStorageChange);
};
}, []);
// Refresh sidebar when trigger changes
useEffect(() => {
if (refreshTrigger > 0) {
refreshSidebar();
}
}, [refreshTrigger, refreshSidebar]);
// Map the items with translations
const translatedItems = newSidebarItems.map(item => ({
...item,
name: getTranslatedName(item.name, t)
}));
return { items: translatedItems, isLoading: newLoading };
};
// Helper function to get translated names
// This maps the page config names to translation keys
const getTranslatedName = (name: string, t: (key: string) => string): string => {
const translationMap: Record<string, string> = {
'Dashboard': t('nav.dashboard'),
'Dateien': t('nav.files'),
'Workflows': t('nav.workflows'),
'Connections': t('nav.connections'),
'Team Bereich': t('nav.team'),
'Einstellungen': t('nav.settings'),
'Test Sharepoint': t('nav.testSharepoint'),
'Speech': t('nav.speech'),
'Transkriptverwaltung': t('nav.transcript_management')
};
return translationMap[name] || name;
};
// Backward-compatible hook that returns just the items array
export const useSidebarItems = (): SidebarItem[] => {
const { items } = useSidebarFromPageConfigs();
return items;
};
export default useSidebarFromPageConfigs;

View file

@ -1,70 +0,0 @@
import styles from './HomeStyles/Home.module.css'
import Sidebar from '../../components/Sidebar';
import { PageManager, SidebarProvider } from '../../core/PageManager';
import { useCurrentUser } from '../../hooks/useUsers';
function Home () {
// Ensure user data is loaded and cached in localStorage for privilege checks
const { isLoading: userLoading, error: userError } = useCurrentUser();
// Show loading state while user data is being fetched
if (userLoading) {
return (
<div className={styles.homeContainer}>
<div className={styles.loadingContainer}>
Lade Benutzerdaten...
</div>
</div>
);
}
// Show error state if user data failed to load
if (userError) {
return (
<div className={styles.homeContainer}>
<div className={styles.errorContainer}>
Fehler beim Laden der Benutzerdaten: {userError}
</div>
</div>
);
}
// Loading component
const LoadingComponent = () => (
<div className={styles.loadingContainer}>
Lade Seite...
</div>
);
// Error component
const ErrorComponent = () => (
<div className={styles.errorContainer}>
Seite nicht verfügbar oder deaktiviert
</div>
);
return (
<SidebarProvider>
<div className={styles.homeContainer}>
<div className={styles.body}>
<div className={styles.homeSidebar}>
<Sidebar />
</div>
<div className={styles.homeContent}>
<PageManager
loadingComponent={LoadingComponent}
errorComponent={ErrorComponent}
/>
</div>
</div>
</div>
</SidebarProvider>
);
}
export default Home;

View file

@ -1,65 +0,0 @@
.homeContainer {
position: relative;
background-color: var(--color-bg);
min-height: 100vh;
max-height: 100vh;
width: 100vw;
font-family: var(--font-family);
z-index: 0;
overflow: hidden;
}
.homeContainer::before {
content: "";
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
background-size: 8px 8px;
opacity: 0.4;
z-index: -1;
pointer-events: none;
}
.body {
display: flex;
max-width: 100vw;
height: 100vh;
}
.homeSidebar {
flex-shrink: 0;
}
.homeContent {
height: 100vh;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
/* Page Loader Styles */
.loadingContainer {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: var(--color-text);
font-family: var(--font-family);
font-size: 1rem;
opacity: 0.7;
}
.errorContainer {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
color: var(--color-text);
font-family: var(--font-family);
font-size: 1rem;
opacity: 0.5;
text-align: center;
padding: 20px;
}

View file

@ -1,308 +0,0 @@
import { PrivilegeChecker } from '../core/PageManager/pageInterface';
import { getUserDataCache } from './userCache';
import type { PermissionContext } from '../hooks/usePermissions';
/**
* Privilege Checkers
*
* Read-only access to user data for privilege checking.
* Does not manage user data storage - that's handled by authentication hooks.
*
* Now supports both client-side checks (roles, localStorage) and backend RBAC integration.
*/
// Function to get current user role labels from sessionStorage cache
const getCurrentUserRoleLabels = (): string[] => {
const userData = getUserDataCache();
return Array.isArray(userData?.roleLabels) ? userData.roleLabels : [];
};
// Generic privilege checker for localStorage-based data with expiration
export const createLocalStoragePrivilegeChecker = (
dataKey: string,
timestampKey: string,
expirationHours: number = 24
): PrivilegeChecker => {
return (): boolean => {
try {
const savedData = localStorage.getItem(dataKey);
const timestamp = localStorage.getItem(timestampKey);
if (savedData && timestamp) {
const dataTime = parseInt(timestamp);
const now = Date.now();
const hoursDiff = (now - dataTime) / (1000 * 60 * 60);
return hoursDiff < expirationHours;
}
return false;
} catch (error) {
console.error(`Error checking privilege for ${dataKey}:`, error);
return false;
}
};
};
// Generic privilege checker for user roles/permissions
export const createRolePrivilegeChecker = (
requiredRoles: string[],
getUserRoles: () => string[] | Promise<string[]>
): PrivilegeChecker => {
return async (): Promise<boolean> => {
try {
const userRoles = await getUserRoles();
const hasRequiredRole = requiredRoles.some(role => userRoles.includes(role));
return hasRequiredRole;
} catch (error) {
console.error('Error checking role privilege:', error);
return false;
}
};
};
// Generic privilege checker for feature flags
export const createFeatureFlagChecker = (
featureFlag: string,
getFeatureFlags: () => Record<string, boolean> | Promise<Record<string, boolean>>
): PrivilegeChecker => {
return async (): Promise<boolean> => {
try {
const flags = await getFeatureFlags();
return flags[featureFlag] === true;
} catch (error) {
console.error(`Error checking feature flag ${featureFlag}:`, error);
return false;
}
};
};
// Generic privilege checker for authentication status
export const createAuthPrivilegeChecker = (
isAuthenticated: () => boolean | Promise<boolean>
): PrivilegeChecker => {
return async (): Promise<boolean> => {
try {
return await isAuthenticated();
} catch (error) {
console.error('Error checking authentication status:', error);
return false;
}
};
};
// Helper function to create custom privilege checkers
export const createCustomPrivilegeChecker = (
checkFunction: () => boolean | Promise<boolean>
): PrivilegeChecker => {
return checkFunction;
};
/**
* Create a privilege checker that uses backend RBAC permissions
* This integrates privilegeCheckers with usePermissions for backend-controlled access
*
* @param canViewFunction - The canView function from usePermissions hook
* @param context - Permission context ('UI', 'DATA', or 'RESOURCE')
* @param item - The item/resource path to check permissions for
* @returns A PrivilegeChecker function that checks backend RBAC permissions
*/
export const createRBACPrivilegeChecker = (
canViewFunction: (context: PermissionContext, item: string) => Promise<boolean>,
context: PermissionContext,
item: string
): PrivilegeChecker => {
return async (): Promise<boolean> => {
try {
return await canViewFunction(context, item);
} catch (error) {
console.error(`Error checking RBAC privilege for ${context}:${item}:`, error);
return false;
}
};
};
/**
* Create a privilege checker that combines RBAC with client-side role checks
* First checks backend RBAC, then falls back to client-side role check if RBAC allows
*
* @param canViewFunction - The canView function from usePermissions hook
* @param context - Permission context ('UI', 'DATA', or 'RESOURCE')
* @param item - The item/resource path to check permissions for
* @param requiredRoles - Fallback client-side roles to check if RBAC passes
* @returns A PrivilegeChecker function that checks both RBAC and roles
*/
export const createCombinedPrivilegeChecker = (
canViewFunction: (context: PermissionContext, item: string) => Promise<boolean>,
context: PermissionContext,
item: string,
requiredRoles: string[]
): PrivilegeChecker => {
return async (): Promise<boolean> => {
try {
// First check backend RBAC
const hasRBACAccess = await canViewFunction(context, item);
if (!hasRBACAccess) {
return false;
}
// If RBAC allows, also check client-side roles as additional validation
const userRoleLabels = getCurrentUserRoleLabels();
const hasRequiredRole = requiredRoles.some(role => userRoleLabels.includes(role));
if (hasRequiredRole) {
return true;
}
// If no role match, still allow if RBAC said yes (backend is source of truth)
return hasRBACAccess;
} catch (error) {
console.error(`Error checking combined privilege for ${context}:${item}:`, error);
return false;
}
};
};
/**
* Helper to create RBAC-based privilege checkers for page data
* These checkers will use backend RBAC permissions via usePermissions
*
* Usage in page data:
* import { createRBACPageChecker } from '@/utils/privilegeCheckers';
*
* // In PageManager, initialize with canView function:
* const rbacCheckers = createRBACPageCheckers(canView);
*
* // In page data:
* privilegeChecker: rbacCheckers.forPage('administration/workflows')
*/
export const createRBACPageCheckers = (
canViewFunction: (context: PermissionContext, item: string) => Promise<boolean>
) => {
return {
/**
* Create a privilege checker for a specific page path
* Checks backend RBAC permissions for UI context
*/
forPage: (pagePath: string): PrivilegeChecker => {
return createRBACPrivilegeChecker(canViewFunction, 'UI', pagePath);
},
/**
* Create a privilege checker that combines RBAC with role requirements
* First checks backend RBAC, then validates user role
*/
forPageWithRole: (
pagePath: string,
requiredRoles: string[]
): PrivilegeChecker => {
return createCombinedPrivilegeChecker(canViewFunction, 'UI', pagePath, requiredRoles);
},
/**
* Create a privilege checker for a data resource
* Checks backend RBAC permissions for DATA context
*/
forData: (resourcePath: string): PrivilegeChecker => {
return createRBACPrivilegeChecker(canViewFunction, 'DATA', resourcePath);
},
/**
* Create a privilege checker for a UI resource
* Checks backend RBAC permissions for UI context
*/
forUI: (resourcePath: string): PrivilegeChecker => {
return createRBACPrivilegeChecker(canViewFunction, 'UI', resourcePath);
}
};
};
// Predefined privilege checkers for common use cases
export const privilegeCheckers = {
// Speech signup checker (existing functionality)
speechSignup: createLocalStoragePrivilegeChecker(
'speechSignUpData',
'speechSignUpTimestamp',
24
),
// Admin role checker - for admin and sysadmin users
adminRole: createRolePrivilegeChecker(
['admin', 'sysadmin'],
() => {
const userRoleLabels = getCurrentUserRoleLabels();
return Promise.resolve(userRoleLabels);
}
),
// Sysadmin role checker - for sysadmin only
sysadminRole: createRolePrivilegeChecker(
['sysadmin'],
() => {
const userRoleLabels = getCurrentUserRoleLabels();
return Promise.resolve(userRoleLabels);
}
),
// Premium user checker
premiumUser: createLocalStoragePrivilegeChecker(
'premiumUserData',
'premiumUserTimestamp',
24 * 30 // 30 days
),
// Feature flag checker
betaFeatures: createFeatureFlagChecker(
'betaFeatures',
() => {
const flags = JSON.parse(localStorage.getItem('featureFlags') || '{}');
return Promise.resolve(flags);
}
),
// Authentication checker
authenticated: createAuthPrivilegeChecker(
() => {
const token = localStorage.getItem('authToken');
return Promise.resolve(!!token);
}
),
// User role checker - for user, admin, and sysadmin access
userRole: createRolePrivilegeChecker(
['user', 'admin', 'sysadmin'],
() => {
const userRoleLabels = getCurrentUserRoleLabels();
return Promise.resolve(userRoleLabels);
}
),
// Viewer role checker - for viewer, user, admin, and sysadmin access (all levels)
viewerRole: createRolePrivilegeChecker(
['viewer', 'user', 'admin', 'sysadmin'],
() => {
const userRoleLabels = getCurrentUserRoleLabels();
return Promise.resolve(userRoleLabels);
}
),
// Subscription checker - for paid features
hasSubscription: createLocalStoragePrivilegeChecker(
'subscriptionData',
'subscriptionTimestamp',
24 * 7 // 7 days
),
// Mandate checker - for users who have submitted their mandate
hasMandate: createLocalStoragePrivilegeChecker(
'mandateData',
'mandateTimestamp',
24 * 30 // 30 days
),
// Always allow access (for public pages)
alwaysAllow: createCustomPrivilegeChecker(() => true),
// Never allow access (for disabled features)
neverAllow: createCustomPrivilegeChecker(() => false)
};