removed legacy code
This commit is contained in:
parent
04590b78c9
commit
386b710c53
57 changed files with 12 additions and 16487 deletions
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 */
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
// Export page data and utilities
|
||||
export * from './pages';
|
||||
|
||||
// Re-export the page interface
|
||||
export * from '../pageInterface';
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
];
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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)
|
||||
};
|
||||
Loading…
Reference in a new issue