Compare commits

...

6 commits

38 changed files with 3504 additions and 1769 deletions

View file

@ -39,7 +39,7 @@ import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
import { FeatureViewPage } from './pages/FeatureView';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminUserRoleTemplatesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
@ -206,14 +206,14 @@ function App() {
<Route index element={<Navigate to="/admin/access" replace />} />
<Route path="mandates" element={<AdminMandatesPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="user-mandates" element={<AdminUserMandatesPage />} />
<Route path="user-mandates" element={<Navigate to="/admin/mandates" replace />} />
<Route path="access" element={<AccessManagementHub />} />
<Route path="feature-instances" element={<AdminFeatureAccessPage />} />
<Route path="feature-roles" element={<AdminFeatureRolesPage />} />
<Route path="feature-users" element={<AdminFeatureInstanceUsersPage />} />
<Route path="invitations" element={<AdminInvitationsPage />} />
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
<Route path="user-role-templates" element={<AdminUserRoleTemplatesPage />} />
<Route path="mandate-roles" element={<Navigate to="/admin/user-role-templates" replace />} />
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
<Route path="billing">
<Route index element={<Navigate to="/billing/admin" replace />} />

View file

@ -175,6 +175,16 @@
box-shadow: 0 3px 8px rgba(74, 111, 165, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.actionButton.roles {
background: linear-gradient(180deg, #6b9a7a 0%, #38a169 100%);
color: white;
}
.actionButton.roles:hover {
background: linear-gradient(180deg, #38a169 0%, #2f855a 100%);
box-shadow: 0 3px 8px rgba(56, 161, 105, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.actionButton.copy {
background: linear-gradient(180deg, #8494a7 0%, #6b7b8d 100%);
color: white;

View file

@ -0,0 +1,45 @@
.chevronBtn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.12s;
flex-shrink: 0;
border-radius: 3px;
width: 24px;
height: 24px;
color: var(--color-text-secondary, #64748b);
}
.chevronBtn:hover:not(:disabled) {
background: var(--color-primary-light, rgba(74, 111, 165, 0.12));
color: var(--color-primary, #4a6fa5);
}
.chevronBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.chevronBtn:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--color-bg, #fff), 0 0 0 4px rgba(74, 111, 165, 0.45);
}
.chevronIcon {
font-size: 11px;
transition: transform 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.chevronIconExpanded {
transform: rotate(90deg);
}
@media (prefers-reduced-motion: reduce) {
.chevronIcon {
transition: none;
}
}

View file

@ -0,0 +1,68 @@
import React from 'react';
import { FaChevronRight } from 'react-icons/fa';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import styles from './ExpandActionButton.module.css';
export interface ExpandActionButtonProps<T = Record<string, unknown>> {
row: T;
disabled?: boolean | { disabled: boolean; message?: string };
loading?: boolean;
className?: string;
title?: string;
hookData: {
isRowExpanded?: (row: T) => boolean;
toggleExpandedRow?: (row: T) => void;
};
idField?: string;
}
export function ExpandActionButton<T = Record<string, unknown>>({
row,
disabled = false,
loading = false,
className = '',
title,
hookData,
idField = 'id',
}: ExpandActionButtonProps<T>) {
const { t } = useLanguage();
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
if (!hookData?.toggleExpandedRow) {
throw new Error('ExpandActionButton requires hookData.toggleExpandedRow');
}
const isExpanded = hookData.isRowExpanded?.(row) ?? false;
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !loading) {
hookData.toggleExpandedRow!(row);
}
};
const defaultTitle = isExpanded ? t('Zuklappen') : t('Aufklappen');
const finalTitle = isDisabled && disabledMessage ? disabledMessage : (title || defaultTitle);
return (
<button
type="button"
onClick={handleClick}
className={`${styles.chevronBtn} ${className}`.trim()}
title={finalTitle}
disabled={isDisabled || loading}
aria-expanded={isExpanded}
aria-label={finalTitle}
data-row-id={String((row as Record<string, unknown>)[idField] ?? '')}
>
<FaChevronRight
className={`${styles.chevronIcon} ${isExpanded ? styles.chevronIconExpanded : ''}`}
aria-hidden
/>
</button>
);
}
export default ExpandActionButton;

View file

@ -0,0 +1,2 @@
export { ExpandActionButton } from './ExpandActionButton';
export type { ExpandActionButtonProps } from './ExpandActionButton';

View file

@ -0,0 +1,91 @@
import React, { useState } from 'react';
import { FaShieldAlt } from 'react-icons/fa';
import { useLanguage } from '../../../../providers/language/LanguageContext';
import { Popup } from '../../../UiComponents/Popup';
import { MandateRolesPermissionsPanel } from '../../../admin/MandateRolesPermissionsPanel';
import { mandateDisplayLabel } from '../../../../utils/mandateDisplayUtils';
import styles from '../ActionButton.module.css';
export interface RolesActionButtonProps<T = Record<string, unknown>> {
row: T;
disabled?: boolean | { disabled: boolean; message?: string };
loading?: boolean;
className?: string;
title?: string;
idField?: string;
labelField?: string;
nameField?: string;
}
export function RolesActionButton<T = Record<string, unknown>>({
row,
disabled = false,
loading = false,
className = '',
title,
idField = 'id',
labelField = 'label',
nameField = 'name',
}: RolesActionButtonProps<T>) {
const { t } = useLanguage();
const [isPopupOpen, setIsPopupOpen] = useState(false);
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
const rowRecord = row as Record<string, unknown>;
const mandateId = String(rowRecord[idField] ?? '');
const mandateLabel = mandateDisplayLabel({
label: rowRecord[labelField] as string | undefined,
name: rowRecord[nameField] as string | undefined,
id: mandateId,
});
const handleClick = (e: React.MouseEvent) => {
e.stopPropagation();
if (!isDisabled && !loading && mandateId) {
setIsPopupOpen(true);
}
};
const buttonTitle = title || t('Rollen & Berechtigungen');
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
return (
<>
<button
type="button"
onClick={handleClick}
className={`${styles.actionButton} ${styles.roles} ${loading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
title={finalTitle}
disabled={isDisabled || loading || !mandateId}
>
<span className={styles.actionIcon}>
{loading ? '⏳' : <FaShieldAlt />}
</span>
</button>
<Popup
isOpen={isPopupOpen}
title={`${t('Rollen-Berechtigungen')}: ${mandateLabel}`}
onClose={() => setIsPopupOpen(false)}
size="xlarge"
closable={true}
closeOnBackdropClick={false}
>
{isPopupOpen && mandateId && (
<MandateRolesPermissionsPanel
mandateId={mandateId}
mandateLabel={mandateLabel}
showRoleActions={true}
showCreateRole={true}
scopeFilter="mandate"
showInfoBox={true}
/>
)}
</Popup>
</>
);
}
export default RolesActionButton;

View file

@ -0,0 +1,2 @@
export { RolesActionButton } from './RolesActionButton';
export type { RolesActionButtonProps } from './RolesActionButton';

View file

@ -5,6 +5,8 @@ export { ViewActionButton } from './ViewActionButton';
export { CopyActionButton } from './CopyActionButton';
export { RemoveActionButton } from './RemoveActionButton';
export { DownloadActionButton } from './DownloadActionButton';
export { RolesActionButton } from './RolesActionButton';
export { ExpandActionButton } from './ExpandActionButton';
// Generic Custom Action Button (for entity-specific actions)
export { CustomActionButton } from './CustomActionButton';
@ -16,4 +18,6 @@ export type { ViewActionButtonProps } from './ViewActionButton';
export type { CopyActionButtonProps } from './CopyActionButton';
export type { RemoveActionButtonProps } from './RemoveActionButton';
export type { DownloadActionButtonProps } from './DownloadActionButton';
export type { RolesActionButtonProps } from './RolesActionButton';
export type { ExpandActionButtonProps } from './ExpandActionButton';
export type { CustomActionButtonProps } from './CustomActionButton';

View file

@ -7,6 +7,7 @@ import {
DeleteActionButton,
ViewActionButton,
CopyActionButton,
RolesActionButton,
CustomActionButton
} from '../ActionButtons';
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
@ -52,7 +53,7 @@ export interface FormGeneratorListProps<T = any> {
loading?: boolean;
emptyMessage?: string;
actionButtons?: {
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play';
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'roles' | 'connect' | 'play';
onAction?: (row: T) => Promise<void> | void;
disabled?: (row: T, hookData?: any) => boolean | { disabled: boolean; message?: string };
loading?: (row: T, hookData?: any) => boolean;
@ -858,6 +859,18 @@ export function FormGeneratorList<T extends Record<string, any>>({
return <ViewActionButton key={actionIndex} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
case 'copy':
return <CopyActionButton key={actionIndex} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />;
case 'roles':
return (
<RolesActionButton
key={actionIndex}
row={row}
disabled={disabledResult}
loading={isLoading}
className={actionButton.className}
title={actionTitle}
idField={actionButton.idField ?? 'id'}
/>
);
case 'connect':
return <CustomActionButton
key={actionIndex}

View file

@ -0,0 +1,67 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import styles from './FormGeneratorTable.module.css';
export interface ExpandableDetailRowProps {
open: boolean;
colSpan: number;
children: React.ReactNode;
}
/**
* Table detail row with smooth expand/collapse (grid 0fr 1fr).
* Stays mounted until the close animation finishes.
*/
export function ExpandableDetailRow({ open, colSpan, children }: ExpandableDetailRowProps) {
const [mounted, setMounted] = useState(open);
const [revealed, setRevealed] = useState(false);
const openRef = useRef(open);
openRef.current = open;
useEffect(() => {
if (open) {
setMounted(true);
const id = requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (openRef.current) setRevealed(true);
});
});
return () => cancelAnimationFrame(id);
}
setRevealed(false);
const fallback = window.setTimeout(() => {
if (!openRef.current) setMounted(false);
}, 420);
return () => window.clearTimeout(fallback);
}, [open]);
const handleTransitionEnd = useCallback((e: React.TransitionEvent<HTMLDivElement>) => {
if (e.target !== e.currentTarget) return;
if (e.propertyName !== 'grid-template-rows') return;
if (!openRef.current) {
setMounted(false);
}
}, []);
if (!mounted) return null;
return (
<tr className={styles.expandedDetailRow}>
<td
colSpan={colSpan}
className={`${styles.expandedDetailCell} ${revealed ? styles.expandedDetailCellOpen : ''}`}
onClick={(e) => e.stopPropagation()}
>
<div
className={`${styles.expandedCollapsible} ${revealed ? styles.expandedCollapsibleOpen : ''}`}
onTransitionEnd={handleTransitionEnd}
>
<div className={styles.expandedCollapsibleInner}>
<div className={styles.expandedDetailInner}>{children}</div>
</div>
</div>
</td>
</tr>
);
}
export default ExpandableDetailRow;

View file

@ -578,6 +578,79 @@
cursor: pointer;
}
.tr.expandedParentRow {
background: rgba(var(--color-secondary-rgb, 74, 111, 165), 0.06);
}
.expandedDetailRow {
background: var(--bg-secondary, #f8fafc);
}
.expandedDetailRow:hover {
background: var(--bg-secondary, #f8fafc);
box-shadow: none;
}
.expandedDetailCell {
padding: 0 16px;
vertical-align: top;
border-bottom: 2px solid transparent;
box-sizing: border-box;
transition:
padding 0.32s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.32s cubic-bezier(0.4, 0, 0.2, 1);
}
.expandedDetailCellOpen {
padding: 12px 16px 16px;
border-bottom-color: var(--color-border, #e2e8f0);
}
.expandedCollapsible {
display: grid;
grid-template-rows: 0fr;
opacity: 0;
transition:
grid-template-rows 0.36s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.28s cubic-bezier(0.4, 0, 0.2, 1);
}
.expandedCollapsibleOpen {
grid-template-rows: 1fr;
opacity: 1;
}
.expandedCollapsibleInner {
overflow: hidden;
min-height: 0;
}
.expandedDetailInner {
width: 100%;
max-width: 100%;
min-width: 0;
overflow-x: auto;
overflow-y: visible;
transform: translateY(-6px);
transition: transform 0.36s cubic-bezier(0.4, 0, 0.2, 1);
}
.expandedCollapsibleOpen .expandedDetailInner {
transform: translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.expandedDetailCell,
.expandedCollapsible,
.expandedDetailInner {
transition: none;
}
.expandedCollapsibleOpen .expandedDetailInner {
transform: none;
}
}
/* Items that live inside a group — subtle tint + left connector */
.tr.groupedItem {
border-left: 3px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 35%, transparent);

View file

@ -64,6 +64,8 @@ import {
DeleteActionButton,
ViewActionButton,
CopyActionButton,
RolesActionButton,
ExpandActionButton,
CustomActionButton
} from '../ActionButtons';
import { formatUnixTimestamp } from '../../../utils/time';
@ -77,6 +79,7 @@ import {
isNumberType,
} from '../../../utils/attributeTypeMapper';
import type { AttributeType } from '../../../utils/attributeTypeMapper';
import { ExpandableDetailRow } from './ExpandableDetailRow';
import { FaFilter } from 'react-icons/fa';
import api from '../../../api';
import { PeriodPicker } from '../../PeriodPicker';
@ -264,7 +267,7 @@ export interface FormGeneratorTableProps<T = any> {
idField?: string; // Field name for unique row identifier (default: 'id')
// Standard action buttons (edit, delete, view, copy, connect, play)
actionButtons?: {
type: 'edit' | 'delete' | 'view' | 'copy' | 'connect' | 'play';
type: 'expand' | 'edit' | 'delete' | 'view' | 'copy' | 'roles' | 'connect' | 'play';
onAction?: (row: T) => Promise<void> | void;
visible?: (row: T, hookData?: any) => boolean;
disabled?: (row: T, hookData?: any) => boolean | { disabled: boolean; message?: string };
@ -355,6 +358,8 @@ export interface FormGeneratorTableProps<T = any> {
csvExportContextFilters?: Record<string, unknown>;
/** Optional download filename token (sanitized), e.g. group key for nested exports. */
csvExportFilenameSuffix?: string;
/** Renders content in a full-width row below the data row when expanded (requires hookData.isRowExpanded / toggleExpandedRow). */
renderExpandedRow?: (row: T) => React.ReactNode;
}
const _FILTER_PAGE_SIZE = 100;
@ -712,6 +717,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
csvExportQueryParams,
csvExportContextFilters,
csvExportFilenameSuffix,
renderExpandedRow,
}: FormGeneratorTableProps<T>) {
const { t, currentLanguage: contextLanguage } = useLanguage();
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
@ -2644,78 +2650,123 @@ export function FormGeneratorTable<T extends Record<string, any>>({
batchActions.length > 0 ||
(selectable && selectedIds.size > 0);
const expandActionConfig = useMemo(
() => actionButtons.find((ab) => ab.type === 'expand'),
[actionButtons],
);
const _renderDataRow = (row: T, index: number) => {
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
const rowId = _getRowId(row);
const isExpanded =
!!renderExpandedRow &&
!!hookData?.isRowExpanded &&
hookData.isRowExpanded(row);
const detailColSpan =
(selectable ? 1 : 0) + (hasActionColumn ? 1 : 0) + detectedColumns.length;
const _renderExpandButton = () => {
if (!expandActionConfig) return null;
const ab = expandActionConfig;
if (ab.visible && !ab.visible(row, hookData)) return null;
const abTitle = typeof ab.title === 'function' ? ab.title(row) : ab.title;
let dis: boolean | { disabled: boolean; message?: string } = false;
if (ab.disabled) {
dis = ab.disabled(row, hookData);
}
const isLd = ab.loading ? ab.loading(row, hookData) : false;
return (
<ExpandActionButton
key="expand"
row={row}
disabled={dis}
loading={isLd}
className={[compact ? actionBtnStyles.compact : '', ab.className ?? ''].filter(Boolean).join(' ')}
title={abTitle}
idField={ab.idField ?? idField}
hookData={hookData}
/>
);
};
return (
<tr
key={rowId || index}
className={`${styles.tr} ${selectedIds.has(rowId) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
onClick={() => onRowClick?.(row, index)}
draggable={rowDraggable}
onDragStart={(e) => {
if (rowDraggable && onRowDragStart) onRowDragStart(e, row);
}}
onDragEnd={() => {}}
{...Object.fromEntries(Object.entries(dataAttributes).map(([k, v]) => [`data-${k}`, v]))}
>
{selectable && (
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<input type="checkbox"
checked={selectedIds.has(rowId)}
onChange={() => handleRowSelect(row)}
onClick={(e) => e.stopPropagation()}
disabled={isRowSelectable && !isRowSelectable(row)}
title={isRowSelectable && !isRowSelectable(row) ? t('Dieses Element kann nicht ausgewählt werden') : t('Dieses Element auswählen')}
style={{ opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1, cursor: isRowSelectable && !isRowSelectable(row) ? 'not-allowed' : 'pointer' }}
/>
</td>
)}
{hasActionColumn && (
<td className={styles.actionsColumn} style={compact ? undefined : { width: `${currentActionsWidth}px`, minWidth: `${defaultActionsWidth}px` }}>
<div ref={(el) => { if (el) actionButtonsRefs.current.set(index, el); else actionButtonsRefs.current.delete(index); }}
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}>
{actionButtons.map((ab, ai) => {
if (ab.visible && !ab.visible(row, hookData)) return null;
const abTitle = typeof ab.title === 'function' ? ab.title(row) : ab.title;
let dis: boolean | { disabled: boolean; message?: string } = false;
if (ab.disabled) { dis = ab.disabled(row, hookData); }
else if (row._permissions) {
if (ab.type === 'edit' && row._permissions.canUpdate === false) dis = true;
else if (ab.type === 'delete' && row._permissions.canDelete === false) dis = true;
}
const isLd = ab.loading ? ab.loading(row) : false;
const isProc = ab.isProcessing ? ab.isProcessing(row) : false;
const bp = { row, disabled: dis, loading: isLd, className: [compact ? actionBtnStyles.compact : '', ab.className ?? ''].filter(Boolean).join(' '), title: abTitle, idField: ab.idField ?? 'id', nameField: ab.nameField ?? 'name', typeField: ab.typeField ?? 'type', contentField: ab.contentField ?? 'content', operationName: ab.operationName, loadingStateName: ab.loadingStateName };
switch (ab.type) {
case 'edit': return <EditActionButton key={`a-${ai}`} {...bp} onEdit={ab.onAction} hookData={hookData} />;
case 'delete': return <DeleteActionButton key={`a-${ai}`} {...bp} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
case 'view': return <ViewActionButton key={`a-${ai}`} {...bp} onView={ab.onAction || (() => {})} isViewing={isProc} hookData={hookData} />;
case 'copy': return <CopyActionButton key={`a-${ai}`} {...bp} onCopy={ab.onAction} isCopying={isProc} contentField={ab.contentField} />;
default: return null;
}
})}
{customActions.map((ca) => (
<CustomActionButton key={`ca-${ca.id}`} row={row} id={ca.id} icon={ca.icon}
onClick={ca.onClick} visible={ca.visible} disabled={ca.disabled}
loading={ca.loading} title={ca.title} className={ca.className}
hookData={hookData} idField={ca.idField ?? 'id'} />
))}
</div>
</td>
)}
{detectedColumns.map((col) => {
const cv = row[col.key];
const cCls = col.cellClassName ? col.cellClassName(cv, row) : '';
const aStyle = _columnAlignStyle(col);
return (
<td key={col.key} className={`${styles.td} ${cCls}`.trim()}
style={{ width: columnWidths[col.key] || col.width || 150, minWidth: columnWidths[col.key] || col.width || 150, maxWidth: columnWidths[col.key] || col.width || 150, ...aStyle }}>
{formatCellValue(cv, col, row)}
<React.Fragment key={rowId || index}>
<tr
className={`${styles.tr} ${selectedIds.has(rowId) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''} ${isExpanded ? styles.expandedParentRow : ''}`}
onClick={() => onRowClick?.(row, index)}
draggable={rowDraggable}
onDragStart={(e) => {
if (rowDraggable && onRowDragStart) onRowDragStart(e, row);
}}
onDragEnd={() => {}}
{...Object.fromEntries(Object.entries(dataAttributes).map(([k, v]) => [`data-${k}`, v]))}
>
{selectable && (
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
<input type="checkbox"
checked={selectedIds.has(rowId)}
onChange={() => handleRowSelect(row)}
onClick={(e) => e.stopPropagation()}
disabled={isRowSelectable && !isRowSelectable(row)}
title={isRowSelectable && !isRowSelectable(row) ? t('Dieses Element kann nicht ausgewählt werden') : t('Dieses Element auswählen')}
style={{ opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1, cursor: isRowSelectable && !isRowSelectable(row) ? 'not-allowed' : 'pointer' }}
/>
</td>
);
})}
</tr>
)}
{hasActionColumn && (
<td className={styles.actionsColumn} style={compact ? undefined : { width: `${currentActionsWidth}px`, minWidth: `${defaultActionsWidth}px` }}>
<div ref={(el) => { if (el) actionButtonsRefs.current.set(index, el); else actionButtonsRefs.current.delete(index); }}
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}>
{_renderExpandButton()}
{actionButtons.map((ab, ai) => {
if (ab.type === 'expand') return null;
if (ab.visible && !ab.visible(row, hookData)) return null;
const abTitle = typeof ab.title === 'function' ? ab.title(row) : ab.title;
let dis: boolean | { disabled: boolean; message?: string } = false;
if (ab.disabled) { dis = ab.disabled(row, hookData); }
else if (row._permissions) {
if (ab.type === 'edit' && row._permissions.canUpdate === false) dis = true;
else if (ab.type === 'delete' && row._permissions.canDelete === false) dis = true;
}
const isLd = ab.loading ? ab.loading(row, hookData) : false;
const isProc = ab.isProcessing ? ab.isProcessing(row) : false;
const bp = { row, disabled: dis, loading: isLd, className: [compact ? actionBtnStyles.compact : '', ab.className ?? ''].filter(Boolean).join(' '), title: abTitle, idField: ab.idField ?? 'id', nameField: ab.nameField ?? 'name', typeField: ab.typeField ?? 'type', contentField: ab.contentField ?? 'content', operationName: ab.operationName, loadingStateName: ab.loadingStateName };
switch (ab.type) {
case 'edit': return <EditActionButton key={`a-${ai}`} {...bp} onEdit={ab.onAction} hookData={hookData} />;
case 'delete': return <DeleteActionButton key={`a-${ai}`} {...bp} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
case 'view': return <ViewActionButton key={`a-${ai}`} {...bp} onView={ab.onAction || (() => {})} isViewing={isProc} hookData={hookData} />;
case 'copy': return <CopyActionButton key={`a-${ai}`} {...bp} onCopy={ab.onAction} isCopying={isProc} contentField={ab.contentField} />;
case 'roles': return <RolesActionButton key={`a-${ai}`} row={row} disabled={dis} loading={isLd} className={bp.className} title={abTitle} idField={ab.idField ?? 'id'} />;
default: return null;
}
})}
{customActions.map((ca) => (
<CustomActionButton key={`ca-${ca.id}`} row={row} id={ca.id} icon={ca.icon}
onClick={ca.onClick} visible={ca.visible} disabled={ca.disabled}
loading={ca.loading} title={ca.title} className={ca.className}
hookData={hookData} idField={ca.idField ?? 'id'} />
))}
</div>
</td>
)}
{detectedColumns.map((col) => {
const cv = row[col.key];
const cCls = col.cellClassName ? col.cellClassName(cv, row) : '';
const aStyle = _columnAlignStyle(col);
return (
<td key={col.key} className={`${styles.td} ${cCls}`.trim()}
style={{ width: columnWidths[col.key] || col.width || 150, minWidth: columnWidths[col.key] || col.width || 150, maxWidth: columnWidths[col.key] || col.width || 150, ...aStyle }}>
{formatCellValue(cv, col, row)}
</td>
);
})}
</tr>
{renderExpandedRow && (
<ExpandableDetailRow open={isExpanded} colSpan={detailColSpan}>
{renderExpandedRow(row)}
</ExpandableDetailRow>
)}
</React.Fragment>
);
};

View file

@ -46,6 +46,12 @@
min-width: 600px;
}
.xlarge {
width: min(1400px, 96vw);
max-width: 96vw;
min-width: min(1100px, 96vw);
}
.fullscreen {
width: 95vw;
height: 95vh;
@ -186,7 +192,9 @@
.content {
padding: 24px;
overflow-y: auto;
overflow-x: hidden;
flex: 1;
min-width: 0;
}
/* Footer section */

View file

@ -21,7 +21,7 @@ export interface PopupProps {
children: React.ReactNode;
footerContent?: React.ReactNode;
className?: string;
size?: 'small' | 'medium' | 'large' | 'fullscreen';
size?: 'small' | 'medium' | 'large' | 'xlarge' | 'fullscreen';
closable?: boolean;
closeOnBackdropClick?: boolean;
closeOnEscape?: boolean;

View file

@ -0,0 +1,163 @@
import React, { useState, useRef, useEffect } from 'react';
import styles from './MandateInfoPanel.module.css';
export type InlineFieldType = 'text' | 'number' | 'boolean' | 'textarea' | 'email';
export interface InlineEditableFieldProps {
label: string;
value: unknown;
fieldKey: string;
type?: InlineFieldType;
editable?: boolean;
saving?: boolean;
onSave: (fieldKey: string, value: unknown) => Promise<void>;
}
function displayValue(value: unknown, type: InlineFieldType, t: (k: string) => string): string {
if (type === 'boolean') {
if (value === true) return t('Ja');
if (value === false) return t('Nein');
return '—';
}
if (value === null || value === undefined || value === '') return '—';
return String(value);
}
export const InlineEditableField: React.FC<
InlineEditableFieldProps & { t: (key: string) => string }
> = ({
label,
value,
fieldKey,
type = 'text',
editable = false,
saving = false,
onSave,
t,
}) => {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState('');
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
useEffect(() => {
if (editing && inputRef.current) {
inputRef.current.focus();
if (type !== 'boolean' && inputRef.current instanceof HTMLInputElement) {
inputRef.current.select();
}
}
}, [editing, type]);
const startEdit = () => {
if (!editable || saving || type === 'boolean') return;
setDraft(value === null || value === undefined ? '' : String(value));
setEditing(true);
};
const commit = async () => {
if (!editing) return;
setEditing(false);
let next: unknown = draft;
if (type === 'number') {
const n = Number(draft);
if (Number.isNaN(n)) return;
next = n;
}
if (String(value ?? '') === String(next ?? '')) return;
await onSave(fieldKey, next);
};
const cancel = () => {
setEditing(false);
setDraft('');
};
const handleBooleanToggle = async () => {
if (!editable || saving) return;
const next = value !== true;
await onSave(fieldKey, next);
};
const shown = displayValue(value, type, t);
const muted = shown === '—';
if (type === 'boolean') {
return (
<>
<dt className={styles.fieldLabel}>{label}</dt>
<dd className={styles.fieldValue}>
<button
type="button"
className={`${styles.boolToggle} ${editable ? styles.editableValue : ''} ${saving ? styles.saving : ''}`}
onClick={(e) => {
e.stopPropagation();
void handleBooleanToggle();
}}
disabled={!editable || saving}
>
{saving ? t('Speichern…') : shown}
</button>
</dd>
</>
);
}
return (
<>
<dt className={styles.fieldLabel}>{label}</dt>
<dd className={styles.fieldValue}>
{editing ? (
type === 'textarea' ? (
<textarea
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
className={styles.inlineInput}
rows={3}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => void commit()}
onKeyDown={(e) => {
if (e.key === 'Escape') cancel();
}}
onClick={(e) => e.stopPropagation()}
/>
) : (
<input
ref={inputRef as React.RefObject<HTMLInputElement>}
type={type === 'email' ? 'email' : type === 'number' ? 'number' : 'text'}
className={styles.inlineInput}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={() => void commit()}
onKeyDown={(e) => {
if (e.key === 'Enter') void commit();
if (e.key === 'Escape') cancel();
}}
onClick={(e) => e.stopPropagation()}
/>
)
) : (
<span
role={editable ? 'button' : undefined}
tabIndex={editable ? 0 : undefined}
className={`${styles.valueClickable} ${editable ? styles.editableValue : ''} ${muted ? styles.fieldValueMuted : ''} ${saving ? styles.saving : ''}`}
onClick={(e) => {
e.stopPropagation();
startEdit();
}}
onKeyDown={(e) => {
if (editable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
startEdit();
}
}}
title={editable ? t('Klicken zum Bearbeiten') : undefined}
>
{saving ? t('Speichern…') : shown}
</span>
)}
</dd>
</>
);
};
export default InlineEditableField;

View file

@ -0,0 +1,102 @@
.root {
position: relative;
width: 100%;
min-width: 0;
max-width: 100%;
}
.trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 100%;
padding: 4px 8px;
font-size: 12px;
font-family: var(--font-family);
color: var(--color-text);
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
cursor: pointer;
text-align: left;
transition: border-color 0.12s, box-shadow 0.12s;
}
.trigger:hover:not(:disabled) {
border-color: var(--color-primary, #4a6fa5);
}
.triggerOpen {
border-color: var(--color-primary, #4a6fa5);
box-shadow: 0 0 0 2px rgba(74, 111, 165, 0.15);
}
.triggerDisabled {
opacity: 0.65;
cursor: not-allowed;
}
.triggerLabel {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.triggerChevron {
flex-shrink: 0;
font-size: 10px;
color: var(--color-text-secondary, #64748b);
transition: transform 0.15s ease;
}
.triggerChevronOpen {
transform: rotate(180deg);
}
.dropdownPortal {
max-height: 220px;
overflow-y: auto;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
.empty {
padding: 10px 12px;
font-size: 12px;
color: var(--color-text-secondary, #64748b);
}
.optionList {
list-style: none;
margin: 0;
padding: 4px 0;
}
.option {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
user-select: none;
}
.option:hover {
background: var(--color-primary-light, rgba(74, 111, 165, 0.08));
}
.optionLocked {
opacity: 0.55;
cursor: not-allowed;
}
.option input {
accent-color: var(--color-primary, #4a6fa5);
margin: 0;
flex-shrink: 0;
}

View file

@ -0,0 +1,191 @@
/**
* InlineRoleMultiselect compact dropdown multiselect for mandate role assignment in tables.
* Dropdown renders in a portal so it is not clipped by table overflow containers.
*/
import React, { useState, useRef, useEffect, useLayoutEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { FaChevronDown } from 'react-icons/fa';
import { useLanguage } from '../../providers/language/LanguageContext';
import styles from './InlineRoleMultiselect.module.css';
export interface InlineRoleMultiselectOption {
value: string;
label: string;
}
export interface InlineRoleMultiselectProps {
value: string[];
options: InlineRoleMultiselectOption[];
onChange: (roleIds: string[]) => void | Promise<void>;
disabled?: boolean;
loading?: boolean;
}
export const InlineRoleMultiselect: React.FC<InlineRoleMultiselectProps> = ({
value,
options,
onChange,
disabled = false,
loading = false,
}) => {
const { t } = useLanguage();
const [open, setOpen] = useState(false);
const [pending, setPending] = useState(false);
const [dropdownRect, setDropdownRect] = useState<{
top: number;
left: number;
width: number;
} | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const selectedSet = useMemo(() => new Set(value), [value]);
const triggerLabel = useMemo(() => {
if (!value.length) return t('Rollen wählen');
const labels = value
.map((id) => options.find((o) => o.value === id)?.label)
.filter(Boolean) as string[];
if (labels.length <= 2) return labels.join(', ');
return t('{count} Rollen', { count: String(value.length) });
}, [value, options, t]);
const updateDropdownPosition = useCallback(() => {
const el = containerRef.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setDropdownRect({
top: rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 200),
});
}, []);
useLayoutEffect(() => {
if (!open) {
setDropdownRect(null);
return;
}
updateDropdownPosition();
window.addEventListener('scroll', updateDropdownPosition, true);
window.addEventListener('resize', updateDropdownPosition);
return () => {
window.removeEventListener('scroll', updateDropdownPosition, true);
window.removeEventListener('resize', updateDropdownPosition);
};
}, [open, updateDropdownPosition]);
const handleClickOutside = useCallback((event: MouseEvent) => {
const target = event.target as Node;
if (containerRef.current?.contains(target)) return;
const portal = document.getElementById('inline-role-multiselect-portal');
if (portal?.contains(target)) return;
setOpen(false);
}, []);
useEffect(() => {
if (open) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [open, handleClickOutside]);
const handleToggleOption = async (optionValue: string) => {
if (disabled || loading || pending) return;
const next = selectedSet.has(optionValue)
? value.filter((id) => id !== optionValue)
: [...value, optionValue];
if (next.length === 0) return;
setPending(true);
try {
await onChange(next);
} finally {
setPending(false);
}
};
const isBusy = disabled || loading || pending;
const noOptions = options.length === 0;
const dropdownContent =
open && !isBusy && dropdownRect ? (
<div
id="inline-role-multiselect-portal"
className={styles.dropdownPortal}
style={{
position: 'fixed',
top: dropdownRect.top,
left: dropdownRect.left,
width: dropdownRect.width,
zIndex: 10050,
}}
role="listbox"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{noOptions ? (
<div className={styles.empty}>{t('Keine Rollen verfügbar')}</div>
) : (
<ul className={styles.optionList}>
{options.map((opt) => {
const checked = selectedSet.has(opt.value);
const isLastSelected = checked && value.length === 1;
return (
<li key={opt.value}>
<label
className={`${styles.option} ${isLastSelected ? styles.optionLocked : ''}`}
title={isLastSelected ? t('Mindestens eine Rolle erforderlich') : undefined}
>
<input
type="checkbox"
checked={checked}
disabled={isLastSelected}
onChange={() => void handleToggleOption(opt.value)}
/>
<span>{opt.label}</span>
</label>
</li>
);
})}
</ul>
)}
</div>
) : null;
return (
<div className={styles.root} ref={containerRef}>
<button
type="button"
className={`${styles.trigger} ${open ? styles.triggerOpen : ''} ${isBusy || noOptions ? styles.triggerDisabled : ''}`}
onClick={(e) => {
e.stopPropagation();
if (!isBusy) {
if (!open) updateDropdownPosition();
setOpen((v) => !v);
}
}}
disabled={isBusy}
aria-expanded={open}
aria-haspopup="listbox"
title={noOptions ? t('Keine Rollen verfügbar') : undefined}
>
<span className={styles.triggerLabel}>
{pending || loading
? t('Speichern…')
: noOptions
? t('Keine Rollen')
: triggerLabel}
</span>
<FaChevronDown className={`${styles.triggerChevron} ${open ? styles.triggerChevronOpen : ''}`} />
</button>
{typeof document !== 'undefined' && dropdownContent
? createPortal(dropdownContent, document.body)
: null}
</div>
);
};
export default InlineRoleMultiselect;

View file

@ -0,0 +1,30 @@
.dashboard {
display: grid;
grid-template-columns: 1fr 3fr;
gap: 16px;
width: 100%;
min-width: 0;
align-items: stretch;
}
.panel {
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 8px;
background: var(--color-bg, #fff);
padding: 14px 16px;
min-width: 0;
display: flex;
flex-direction: column;
min-height: 0;
}
.panelUsers {
padding: 0;
overflow: hidden;
}
@media (max-width: 900px) {
.dashboard {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,52 @@
/**
* MandateExpandDashboard two-panel layout inside expanded mandate table row.
*/
import React from 'react';
import type { Mandate } from '../../hooks/useMandates';
import { MandateInfoPanel } from './MandateInfoPanel';
import { MandateUsersPanel } from './MandateUsersPanel';
import styles from './MandateExpandDashboard.module.css';
export interface MandateExpandDashboardProps {
mandate: Mandate;
canUpdate: boolean;
fetchMandateById: (id: string) => Promise<Mandate | null>;
handleUpdate: (mandateId: string, updateData: Partial<Mandate>) => Promise<boolean>;
onMandateUpdated?: () => void;
refreshKey?: number;
}
export const MandateExpandDashboard: React.FC<MandateExpandDashboardProps> = ({
mandate,
canUpdate,
fetchMandateById,
handleUpdate,
onMandateUpdated,
refreshKey,
}) => {
return (
<div className={styles.dashboard}>
<div className={styles.panel}>
<MandateInfoPanel
mandateId={mandate.id}
mandateLabel={mandate.label || mandate.name}
canUpdate={canUpdate}
fetchMandateById={fetchMandateById}
handleUpdate={handleUpdate}
onMandateUpdated={onMandateUpdated}
refreshKey={refreshKey}
/>
</div>
<div className={`${styles.panel} ${styles.panelUsers}`}>
<MandateUsersPanel
mandateId={mandate.id}
embedded
embeddedInDashboard
/>
</div>
</div>
);
};
export default MandateExpandDashboard;

View file

@ -0,0 +1,194 @@
.panelInner {
display: flex;
flex-direction: column;
gap: 14px;
min-width: 0;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.headerTitle {
margin: 0;
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary, #0f172a);
line-height: 1.3;
}
.headerSubtitle {
margin: 4px 0 0;
font-size: 0.75rem;
color: var(--text-secondary, #64748b);
font-family: var(--font-family-mono, monospace);
}
.section {
display: flex;
flex-direction: column;
gap: 8px;
}
.sectionTitle {
margin: 0;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-secondary, #64748b);
padding-bottom: 4px;
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
.fieldGrid {
display: grid;
grid-template-columns: minmax(0, 38%) minmax(0, 1fr);
gap: 6px 12px;
font-size: 0.8125rem;
}
.fieldLabel {
color: var(--text-secondary, #64748b);
font-weight: 500;
}
.fieldValue {
color: var(--text-primary, #0f172a);
word-break: break-word;
}
.fieldValueMuted {
color: var(--text-secondary, #94a3b8);
}
.valueClickable {
display: inline-block;
max-width: 100%;
word-break: break-word;
}
.editableValue {
cursor: pointer;
border-radius: 4px;
padding: 2px 4px;
margin: -2px -4px;
transition: background 0.12s;
}
.editableValue:hover {
background: var(--color-primary-light, rgba(74, 111, 165, 0.1));
}
.saving {
opacity: 0.6;
cursor: wait;
}
.inlineInput {
width: 100%;
font-size: 0.8125rem;
font-family: var(--font-family);
padding: 4px 6px;
border: 1px solid var(--color-primary, #4a6fa5);
border-radius: 4px;
box-sizing: border-box;
}
.boolToggle {
background: none;
border: none;
padding: 2px 4px;
margin: -2px -4px;
font: inherit;
color: inherit;
text-align: left;
border-radius: 4px;
}
.addressBlock {
padding: 10px 12px;
background: var(--bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
min-height: 48px;
}
.addressPre {
margin: 0;
font-family: var(--font-family);
font-size: 0.8125rem;
line-height: 1.5;
white-space: pre-wrap;
color: var(--text-primary, #0f172a);
}
.addressEditBox {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
background: var(--bg-secondary, #f8fafc);
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 6px;
}
.addressDoneBtn {
align-self: flex-end;
font-size: 0.75rem;
padding: 4px 10px;
border-radius: 5px;
border: 1px solid var(--color-border, #e2e8f0);
background: var(--color-bg, #fff);
cursor: pointer;
}
.addressDoneBtn:hover {
background: var(--bg-secondary, #f8fafc);
}
.systemHint {
font-size: 0.75rem;
color: var(--text-secondary, #64748b);
padding: 6px 8px;
background: var(--warning-bg, #fffbeb);
border: 1px solid var(--warning-color, #d69e2e);
border-radius: 6px;
}
.billingWarning {
font-size: 0.75rem;
color: var(--warning-color, #d69e2e);
padding: 6px 8px;
background: var(--warning-bg, #fffbeb);
border: 1px solid var(--warning-color, #d69e2e);
border-radius: 6px;
}
.loadingWrap {
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
color: var(--text-secondary);
font-size: 0.8125rem;
gap: 8px;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid var(--color-border, #e2e8f0);
border-top-color: var(--primary-color, #f25843);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}

View file

@ -0,0 +1,329 @@
/**
* MandateInfoPanel inline-editable mandate + billing summary (left expand panel).
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import type { Mandate } from '../../hooks/useMandates';
import { useMandateFormAttributes } from '../../hooks/useMandates';
import { useApiRequest } from '../../hooks/useApi';
import { fetchSettingsAdmin, updateSettingsAdmin, type BillingSettingsUpdate } from '../../api/billingApi';
import {
mergeBillingIntoMandateFormData,
getMandateBillingFormAttributes,
MANDATE_INVOICE_FIELD_NAMES,
formatMandateInvoiceAddress,
isMandateBillingField,
mandateBillingFieldNames,
} from '../../utils/mandateBillingFormMerge';
import { InlineEditableField, type InlineFieldType } from './InlineEditableField';
import { useToast } from '../../contexts/ToastContext';
import styles from './MandateInfoPanel.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
import { getUserDataCache } from '../../utils/userCache';
const STAMMDATEN_FIELDS = ['name', 'label', 'enabled', 'isSystem', 'mfaRequired'] as const;
const FIELD_TYPES: Record<string, InlineFieldType> = {
name: 'text',
label: 'text',
enabled: 'boolean',
isSystem: 'boolean',
mfaRequired: 'boolean',
warningThresholdPercent: 'number',
notifyOnWarning: 'boolean',
notifyEmails: 'textarea',
invoiceEmail: 'email',
};
const MANDATE_ADMIN_EDITABLE = new Set(['label', 'warningThresholdPercent', 'notifyOnWarning', 'notifyEmails']);
export interface MandateInfoPanelProps {
mandateId: string;
mandateLabel?: string;
canUpdate: boolean;
fetchMandateById: (id: string) => Promise<Mandate | null>;
handleUpdate: (mandateId: string, updateData: Partial<Mandate>) => Promise<boolean>;
onMandateUpdated?: () => void;
refreshKey?: number;
}
function isFieldEditable(
fieldName: string,
canUpdate: boolean,
isPlatformAdmin: boolean,
isSystemMandate: boolean,
): boolean {
if (!canUpdate) return false;
if (fieldName === 'isSystem') return false;
if (isSystemMandate && fieldName === 'name') return false;
if (!isPlatformAdmin) {
return MANDATE_ADMIN_EDITABLE.has(fieldName);
}
return fieldName !== 'isSystem' && fieldName !== 'id' && fieldName !== 'deletedAt';
}
function InfoSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
<section className={styles.section}>
<h4 className={styles.sectionTitle}>{title}</h4>
{children}
</section>
);
}
export const MandateInfoPanel: React.FC<MandateInfoPanelProps> = ({
mandateId,
mandateLabel,
canUpdate,
fetchMandateById,
handleUpdate,
onMandateUpdated,
refreshKey = 0,
}) => {
const { t } = useLanguage();
const { request } = useApiRequest();
const { showError, showWarning } = useToast();
const { formAttributes } = useMandateFormAttributes();
const isPlatformAdmin = getUserDataCache()?.isPlatformAdmin === true;
const [data, setData] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [billingWarning, setBillingWarning] = useState<string | null>(null);
const [savingField, setSavingField] = useState<string | null>(null);
const [editingAddress, setEditingAddress] = useState(false);
const labelMap = useMemo(() => {
const map = new Map<string, string>();
for (const attr of formAttributes) {
map.set(attr.name, attr.label || attr.name);
}
for (const attr of getMandateBillingFormAttributes()) {
map.set(attr.name, attr.label || attr.name);
}
return map;
}, [formAttributes]);
const loadDetail = useCallback(async () => {
setLoading(true);
setBillingWarning(null);
try {
const fullMandate = await fetchMandateById(mandateId);
if (!fullMandate) {
setData(null);
return;
}
try {
const settings = await fetchSettingsAdmin(request, mandateId);
setData(mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, settings));
} catch {
setData(mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, null));
setBillingWarning(t('Abrechnungseinstellungen konnten nicht geladen werden.'));
}
} finally {
setLoading(false);
}
}, [mandateId, fetchMandateById, request, t]);
useEffect(() => {
void loadDetail();
}, [loadDetail, refreshKey]);
const isSystemMandate = Boolean(data?.isSystem);
const canEditInvoice = isPlatformAdmin && canUpdate;
const saveField = useCallback(
async (fieldName: string, newValue: unknown) => {
setSavingField(fieldName);
try {
if (isMandateBillingField(fieldName)) {
const billingUpdate: BillingSettingsUpdate = {};
if (fieldName === 'warningThresholdPercent') {
const n = Number(newValue);
if (Number.isNaN(n)) {
showError(t('Fehler'), t('Ungültiger Wert'));
return;
}
billingUpdate.warningThresholdPercent = n;
} else if (fieldName === 'notifyOnWarning') {
billingUpdate.notifyOnWarning = Boolean(newValue);
} else if (fieldName === 'notifyEmails') {
const emails =
typeof newValue === 'string'
? newValue.split(/[\n,;]+/).map((s) => s.trim()).filter(Boolean)
: [];
billingUpdate.notifyEmails = emails;
}
await updateSettingsAdmin(request, mandateId, billingUpdate);
} else {
const ok = await handleUpdate(mandateId, { [fieldName]: newValue } as Partial<Mandate>);
if (!ok) {
showWarning(t('Fehler'), t('Speichern fehlgeschlagen.'));
await loadDetail();
return;
}
}
setData((prev) => (prev ? { ...prev, [fieldName]: newValue } : prev));
onMandateUpdated?.();
} catch (e: unknown) {
console.error(e);
showError(t('Fehler'), t('Speichern fehlgeschlagen.'));
await loadDetail();
} finally {
setSavingField(null);
}
},
[mandateId, request, handleUpdate, loadDetail, onMandateUpdated, showError, showWarning, t],
);
const displayTitle =
mandateLabel ||
(data
? mandateDisplayLabel({
label: data.label as string | undefined,
name: data.name as string | undefined,
id: mandateId,
})
: mandateId);
const formattedAddress = data ? formatMandateInvoiceAddress(data) : null;
if (loading) {
return (
<div className={styles.loadingWrap}>
<div className={styles.spinner} />
<span>{t('Laden…')}</span>
</div>
);
}
if (!data) {
return (
<div className={styles.loadingWrap}>
<span>{t('Mandant konnte nicht geladen werden.')}</span>
</div>
);
}
return (
<div className={styles.panelInner}>
<div className={styles.header}>
<div>
<h3 className={styles.headerTitle}>{t('Mandant')}</h3>
<p className={styles.headerSubtitle}>{displayTitle}</p>
</div>
</div>
{billingWarning && <div className={styles.billingWarning}>{billingWarning}</div>}
{Boolean(data.isSystem) && (
<div className={styles.systemHint}>
<strong>{t('System-Mandant')}</strong> {t('Kurzzeichen ist schreibgeschützt.')}
</div>
)}
<InfoSection title={t('Stammdaten')}>
<dl className={styles.fieldGrid}>
{STAMMDATEN_FIELDS.map((fieldName) => (
<InlineEditableField
key={fieldName}
t={t}
label={labelMap.get(fieldName) || fieldName}
value={data[fieldName]}
fieldKey={fieldName}
type={FIELD_TYPES[fieldName] ?? 'text'}
editable={isFieldEditable(fieldName, canUpdate, isPlatformAdmin, isSystemMandate)}
saving={savingField === fieldName}
onSave={saveField}
/>
))}
</dl>
</InfoSection>
<InfoSection title={t('Rechnungsadresse')}>
{editingAddress && canEditInvoice ? (
<div className={styles.addressEditBox}>
<dl className={styles.fieldGrid}>
{MANDATE_INVOICE_FIELD_NAMES.map((fieldName) => (
<InlineEditableField
key={fieldName}
t={t}
label={labelMap.get(fieldName) || fieldName}
value={data[fieldName]}
fieldKey={fieldName}
type={FIELD_TYPES[fieldName] ?? 'text'}
editable
saving={savingField === fieldName}
onSave={saveField}
/>
))}
</dl>
<button
type="button"
className={styles.addressDoneBtn}
onClick={(e) => {
e.stopPropagation();
setEditingAddress(false);
}}
>
{t('Fertig')}
</button>
</div>
) : (
<div
role={canEditInvoice ? 'button' : undefined}
tabIndex={canEditInvoice ? 0 : undefined}
className={`${styles.addressBlock} ${canEditInvoice ? styles.editableValue : ''}`}
onClick={(e) => {
e.stopPropagation();
if (canEditInvoice) setEditingAddress(true);
}}
onKeyDown={(e) => {
if (canEditInvoice && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
setEditingAddress(true);
}
}}
title={canEditInvoice ? t('Klicken zum Bearbeiten') : undefined}
>
{formattedAddress ? (
<pre className={styles.addressPre}>{formattedAddress}</pre>
) : (
<span className={styles.fieldValueMuted}>
{canEditInvoice ? t('Klicken, um Adresse zu erfassen') : '—'}
</span>
)}
</div>
)}
</InfoSection>
<InfoSection title={t('Abrechnung')}>
<dl className={styles.fieldGrid}>
{mandateBillingFieldNames.map((fieldName) => (
<InlineEditableField
key={fieldName}
t={t}
label={labelMap.get(fieldName) || fieldName}
value={data[fieldName]}
fieldKey={fieldName}
type={FIELD_TYPES[fieldName] ?? 'text'}
editable={isFieldEditable(fieldName, canUpdate, isPlatformAdmin, isSystemMandate)}
saving={savingField === fieldName}
onSave={saveField}
/>
))}
</dl>
</InfoSection>
</div>
);
};
export default MandateInfoPanel;

View file

@ -0,0 +1,243 @@
/* Table-style role list: one row per role card header */
.rolesTable {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.rolesTableHead,
.roleHeaderTable {
display: grid;
align-items: center;
gap: 0.75rem 1rem;
width: 100%;
min-width: 0;
}
.rolesTableHead {
padding: 0.5rem 1.25rem;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-secondary, #666);
border-bottom: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-tertiary, #f8f9fa);
border-radius: 8px 8px 0 0;
}
.roleHeaderTable {
grid-template-columns:
24px
minmax(100px, 0.9fr)
minmax(140px, 2fr)
minmax(120px, 0.75fr)
minmax(64px, 0.35fr);
}
.roleHeaderTableWithActions {
grid-template-columns:
24px
minmax(100px, 0.9fr)
minmax(140px, 2fr)
minmax(120px, 0.75fr)
minmax(64px, 0.35fr)
72px;
}
.rolesTableHeadWithActions {
grid-template-columns:
24px
minmax(100px, 0.9fr)
minmax(140px, 2fr)
minmax(120px, 0.75fr)
minmax(64px, 0.35fr)
72px;
}
.roleHeaderTableRow {
padding: 0.75rem 1.25rem;
cursor: pointer;
transition: background-color 0.2s ease;
}
.roleHeaderTableRow:hover {
background: var(--bg-tertiary, #f8f9fa);
}
.cellExpand {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.cellLabel {
font-weight: 600;
color: var(--text-primary, #1a1a1a);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.cellDescription {
color: var(--text-secondary, #666);
font-size: 0.875rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.cellScope,
.cellUsers {
font-size: 0.875rem;
color: var(--text-secondary, #666);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.cellScope strong,
.cellUsers strong {
color: var(--text-primary, #1a1a1a);
font-weight: 600;
}
.roleHeaderActions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
flex-shrink: 0;
}
.roleActionBtn {
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
border: none;
border-radius: 6px;
font-size: 12px;
cursor: pointer;
min-width: 28px;
min-height: 28px;
transition: all 0.2s ease;
}
.roleActionBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.roleActionBtnEdit {
background: linear-gradient(180deg, #5a82b5 0%, var(--color-secondary, #4A6FA5) 100%);
color: white;
}
.roleActionBtnDelete {
background: linear-gradient(180deg, #8494a7 0%, #6b7b8d 100%);
color: white;
}
.roleActionBtnDelete:hover:not(:disabled) {
background: linear-gradient(180deg, #d44040 0%, var(--color-red, #C53030) 100%);
}
.panelRoot {
min-width: 0;
max-width: 100%;
}
.panelHeaderRow {
display: flex;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1rem;
min-width: 0;
}
.panelInfoBox {
flex: 1;
min-width: 0;
margin-bottom: 0;
align-items: flex-start;
box-sizing: border-box;
}
.panelCreateButton {
flex-shrink: 0;
align-self: flex-start;
white-space: nowrap;
}
.createTemplateField {
margin-bottom: 1.25rem;
}
.createTemplateLabel {
display: block;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary, #1a1a1a);
}
.createTemplateSelect {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.875rem;
background: var(--bg-secondary, #fff);
color: var(--text-primary, #1a1a1a);
}
.createTemplateHint {
margin-top: 0.5rem;
font-size: 0.8125rem;
color: var(--text-secondary, #666);
line-height: 1.4;
}
.panelInfoBoxIcon {
flex-shrink: 0;
margin-top: 0.15em;
}
.panelInfoBoxText {
flex: 1;
min-width: 0;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
line-height: 1.5;
}
.roleContentInfoBox {
align-items: flex-start;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.roleContentInfoBoxText {
flex: 1;
min-width: 0;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
line-height: 1.5;
}
.roleCardTable {
overflow: hidden;
}
.roleCardTable .roleHeaderTableRow {
border: none;
}

View file

@ -0,0 +1,574 @@
/**
* MandateRolesPermissionsPanel
*
* Expandable role list with AccessRulesEditor per role.
* Used in RolesActionButton popup on Admin Mandates.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
useMandateRoles,
type Role,
type RoleUpdate,
} from '../../hooks/useMandateRoles';
import { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
import { AccessRulesEditor } from '../AccessRules';
import { FormGeneratorForm, type AttributeDefinition } from '../FormGenerator/FormGeneratorForm';
import { Popup } from '../UiComponents/Popup';
import { useToast } from '../../contexts/ToastContext';
import { useLanguage } from '../../providers/language/LanguageContext';
import {
FaUserShield,
FaSync,
FaChevronDown,
FaChevronRight,
FaPlus,
} from 'react-icons/fa';
import { MdModeEdit } from 'react-icons/md';
import { IoIosTrash } from 'react-icons/io';
import adminStyles from '../../pages/admin/Admin.module.css';
import panelStyles from './MandateRolesPermissionsPanel.module.css';
export interface MandateRolesPermissionsPanelProps {
mandateId: string;
mandateLabel?: string;
/** When false, hide per-role edit/delete (e.g. standalone permissions page). Default true. */
showRoleActions?: boolean;
/** Backend scope filter. Default mandate-only for mandates popup. */
scopeFilter?: 'all' | 'mandate' | 'global';
/** Show info box above role list. Default true when showRoleActions, else from parent. */
showInfoBox?: boolean;
/** Increment to trigger refetch (e.g. parent refresh button). */
refreshToken?: number;
/** Show «Neue Rolle» button and create flow. Default: same as showRoleActions. */
showCreateRole?: boolean;
}
const SCOPE_FILTER_DEFAULT: MandateRolesPermissionsPanelProps['scopeFilter'] = 'mandate';
function getTextValue(value: string | Record<string, string> | undefined): string {
if (!value) return '';
if (typeof value === 'string') return value;
return Object.values(value).find(v => !!v) || '';
}
function isTemplateRole(role: Role): boolean {
return !!role.isSystemRole || !role.mandateId;
}
export const MandateRolesPermissionsPanel: React.FC<MandateRolesPermissionsPanelProps> = ({
mandateId,
showRoleActions = true,
scopeFilter = SCOPE_FILTER_DEFAULT,
showInfoBox,
refreshToken = 0,
showCreateRole,
}) => {
const { t } = useLanguage();
const { request } = useApiRequest();
const { showError, showWarning } = useToast();
const {
roles,
loading,
error,
fetchRoles,
fetchTemplatesOnly,
createRoleFromTemplate,
updateRole,
deleteRole,
} = useMandateRoles();
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
const [deletingRoleId, setDeletingRoleId] = useState<string | null>(null);
const [userRoleTemplates, setUserRoleTemplates] = useState<Role[]>([]);
const [templatesLoading, setTemplatesLoading] = useState(false);
const [selectedTemplateId, setSelectedTemplateId] = useState('');
const [createFormData, setCreateFormData] = useState<Record<string, unknown>>({});
const shouldShowInfoBox = showInfoBox ?? showRoleActions;
const shouldShowCreateRole = showCreateRole ?? showRoleActions;
const needsRoleFormAttributes = showRoleActions || shouldShowCreateRole;
const loadRoles = useCallback(async () => {
if (!mandateId) return;
await fetchRoles(mandateId, { scopeFilter });
}, [mandateId, scopeFilter, fetchRoles]);
useEffect(() => {
loadRoles();
}, [loadRoles, refreshToken]);
useEffect(() => {
if (needsRoleFormAttributes) {
fetchAttributes(request, 'RoleView')
.then(setBackendAttributes)
.catch(() => setBackendAttributes([]));
}
}, [request, needsRoleFormAttributes]);
useEffect(() => {
if (!showCreateModal || !shouldShowCreateRole) return;
setTemplatesLoading(true);
// fetchTemplatesOnly makes a direct API call without touching the shared
// `roles` state, so the displayed mandate roles are never clobbered.
fetchTemplatesOnly()
.then(setUserRoleTemplates)
.catch(() => setUserRoleTemplates([]))
.finally(() => setTemplatesLoading(false));
}, [showCreateModal, shouldShowCreateRole, fetchTemplatesOnly]);
const existingMandateRoleLabels = useMemo(
() => new Set(roles.map(r => r.roleLabel)),
[roles],
);
const closeCreateModal = () => {
if (isSubmitting) return;
setShowCreateModal(false);
setSelectedTemplateId('');
setCreateFormData({});
};
const handleTemplateSelect = (templateId: string) => {
setSelectedTemplateId(templateId);
const template = userRoleTemplates.find(t => t.id === templateId);
if (template) {
setCreateFormData({
roleLabel: template.roleLabel,
description: template.description ?? {},
});
} else {
setCreateFormData({});
}
};
const scopeTypeLabel = useCallback(
(scopeType?: Role['scopeType']) => {
switch (scopeType) {
case 'system':
return t('System-Template');
case 'global':
return t('Template');
case 'mandate':
return t('Mandant');
default:
return '—';
}
},
[t],
);
const createFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
return backendAttributes
.filter(attr => !excludedFields.includes(attr.name))
.map(attr => ({ ...attr })) as AttributeDefinition[];
}, [backendAttributes]);
const editFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
return backendAttributes
.filter(attr => !excludedFields.includes(attr.name))
.map(attr => ({
...attr,
readonly: attr.name === 'roleLabel' ? true : attr.readonly,
})) as AttributeDefinition[];
}, [backendAttributes]);
const handleCreateRoleFromTemplate = async (data: {
roleLabel: string;
description?: Record<string, string>;
}) => {
if (!mandateId) return;
if (!selectedTemplateId) {
showError(t('Fehler'), t('Bitte eine Rollen-Vorlage wählen'));
return;
}
setIsSubmitting(true);
try {
const result = await createRoleFromTemplate(selectedTemplateId, mandateId, {
roleLabel: data.roleLabel.toLowerCase().replace(/\s+/g, '_'),
description: data.description,
});
if (result.success) {
closeCreateModal();
await loadRoles();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Rolle aus Vorlage'));
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('Fehler beim Erstellen der Rolle aus Vorlage');
showError(t('Fehler'), message);
} finally {
setIsSubmitting(false);
}
};
const toggleRole = (roleId: string) => {
setExpandedRoleId(prev => (prev === roleId ? null : roleId));
};
const handleEditRole = async (data: RoleUpdate) => {
if (!editingRole) return;
setIsSubmitting(true);
try {
const updateData: RoleUpdate = {
roleLabel: data.roleLabel,
description: data.description,
};
const result = await updateRole(editingRole.id, updateData);
if (result.success) {
setEditingRole(null);
await loadRoles();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Rolle'));
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('Fehler beim Aktualisieren der Rolle');
showError(t('Fehler'), message);
} finally {
setIsSubmitting(false);
}
};
const handleDeleteRole = async (role: Role) => {
if (role.isSystemRole) {
showWarning(t('Nicht erlaubt'), t('System-Rollen können nicht gelöscht werden.'));
return;
}
if (!window.confirm(t('Rolle «{label}» wirklich löschen?', { label: role.roleLabel }))) {
return;
}
setDeletingRoleId(role.id);
try {
const result = await deleteRole(role.id);
if (result.success) {
if (expandedRoleId === role.id) {
setExpandedRoleId(null);
}
await loadRoles();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Löschen der Rolle'));
}
} finally {
setDeletingRoleId(null);
}
};
if (error) {
return (
<div className={adminStyles.errorContainer}>
<span className={adminStyles.errorIcon}></span>
<p className={adminStyles.errorMessage}>
{t('Fehler beim Laden')}: {error}
</p>
<button type="button" className={adminStyles.secondaryButton} onClick={() => loadRoles()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
);
}
return (
<div className={panelStyles.panelRoot}>
{(shouldShowInfoBox || shouldShowCreateRole) && (
<div className={panelStyles.panelHeaderRow}>
{shouldShowInfoBox && (
<div className={`${adminStyles.infoBox} ${panelStyles.panelInfoBox}`}>
<FaUserShield
className={panelStyles.panelInfoBoxIcon}
style={{ marginRight: '0.5rem', color: 'var(--text-secondary, #666)' }}
/>
<span className={panelStyles.panelInfoBoxText}>
{t('Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.')}{' '}
<strong>{t('Template-Rollen')}</strong>{' '}
{t('sind schreibgeschützt Änderungen an Templates wirken sich nur auf neu erstellte Mandanten aus.')}{' '}
<strong>{t('Mandanten-Rollen')}</strong> {t('sind direkt bearbeitbar.')}
</span>
</div>
)}
{shouldShowCreateRole && (
<button
type="button"
className={`${adminStyles.primaryButton} ${panelStyles.panelCreateButton}`}
onClick={() => setShowCreateModal(true)}
disabled={loading}
>
<FaPlus /> {t('Neue Rolle')}
</button>
)}
</div>
)}
{loading && (
<div className={adminStyles.loadingContainer}>
<div className={adminStyles.spinner} />
<span>{t('Lade Rollen')}</span>
</div>
)}
{!loading && roles.length === 0 && (
<div className={adminStyles.emptyState}>
<FaUserShield className={adminStyles.emptyIcon} />
<p>{t('Keine Rollen gefunden')}</p>
<p className={adminStyles.emptyHint}>
{scopeFilter === 'mandate'
? t('Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.')
: scopeFilter === 'global'
? t('Es gibt noch keine Rollentemplates')
: t('Es gibt noch keine Rollen')}
</p>
</div>
)}
{!loading && roles.length > 0 && (
<div className={panelStyles.rolesTable}>
<div
className={`${panelStyles.rolesTableHead} ${
showRoleActions ? panelStyles.rolesTableHeadWithActions : ''
}`}
aria-hidden="true"
>
<span />
<span>{t('Bezeichnung')}</span>
<span>{t('Beschreibung')}</span>
<span>{t('Geltungsbereich')}</span>
<span>{t('Benutzer')}</span>
{showRoleActions && <span>{t('Aktionen')}</span>}
</div>
{roles.map(role => {
const systemLocked = !!role.isSystemRole;
const isExpanded = expandedRoleId === role.id;
const headerGridClass = showRoleActions
? panelStyles.roleHeaderTableWithActions
: '';
return (
<div
key={role.id}
className={`${adminStyles.roleCard} ${panelStyles.roleCardTable}`}
>
<div
className={`${panelStyles.roleHeaderTable} ${headerGridClass} ${panelStyles.roleHeaderTableRow}`}
onClick={() => toggleRole(role.id)}
role="button"
tabIndex={0}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleRole(role.id);
}
}}
>
<span className={`${adminStyles.expandIcon} ${panelStyles.cellExpand}`}>
{isExpanded ? <FaChevronDown /> : <FaChevronRight />}
</span>
<span className={panelStyles.cellLabel} title={role.roleLabel}>
{role.roleLabel}
</span>
<span
className={panelStyles.cellDescription}
title={getTextValue(role.description)}
>
{getTextValue(role.description) || '—'}
</span>
<span className={panelStyles.cellScope}>
<strong>{scopeTypeLabel(role.scopeType)}</strong>
</span>
<span className={panelStyles.cellUsers}>
<strong>{role.userCount ?? 0}</strong>
</span>
{showRoleActions && (
<div className={panelStyles.roleHeaderActions}>
<button
type="button"
className={`${panelStyles.roleActionBtn} ${panelStyles.roleActionBtnEdit}`}
title={
systemLocked
? t('System-Rollen können nicht bearbeitet werden')
: t('Rolle bearbeiten')
}
disabled={systemLocked}
onClick={e => {
e.stopPropagation();
if (!systemLocked) setEditingRole(role);
}}
>
<MdModeEdit />
</button>
<button
type="button"
className={`${panelStyles.roleActionBtn} ${panelStyles.roleActionBtnDelete}`}
title={
systemLocked
? t('System-Rollen können nicht gelöscht werden')
: t('Rolle löschen')
}
disabled={systemLocked || deletingRoleId === role.id}
onClick={e => {
e.stopPropagation();
void handleDeleteRole(role);
}}
>
{deletingRoleId === role.id ? '⏳' : <IoIosTrash />}
</button>
</div>
)}
</div>
{isExpanded && (
<div className={adminStyles.roleContent}>
{isTemplateRole(role) && (
<div
className={`${adminStyles.infoBox} ${panelStyles.roleContentInfoBox}`}
style={{
marginBottom: '0.75rem',
background: 'var(--warning-bg, #fffbeb)',
borderColor: 'var(--warning-color, #d69e2e)',
}}
>
<FaUserShield
className={panelStyles.panelInfoBoxIcon}
style={{ marginRight: '0.5rem', color: 'var(--warning-color, #d69e2e)' }}
/>
<span className={panelStyles.roleContentInfoBoxText}>
{t('Dies ist eine')} <strong>{t('Template-Rolle')}</strong>.{' '}
{t(
'Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus. Bestehende Mandanten-Instanzen werden nicht aktualisiert.',
)}
</span>
</div>
)}
<AccessRulesEditor
roleId={role.id}
roleName={role.roleLabel}
isTemplate={isTemplateRole(role)}
readOnly={false}
apiBasePath="/api/rbac"
mandateId={mandateId}
/>
</div>
)}
</div>
);
})}
</div>
)}
{showCreateModal && (
<Popup
isOpen={true}
title={t('Rolle aus Vorlage erstellen')}
onClose={closeCreateModal}
size="medium"
closable={!isSubmitting}
>
{createFields.length === 0 || templatesLoading ? (
<div className={adminStyles.loadingContainer}>
<div className={adminStyles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<>
<div className={panelStyles.createTemplateField}>
<label className={panelStyles.createTemplateLabel} htmlFor="role-template-select">
{t('Rollen-Vorlage')} *
</label>
<select
id="role-template-select"
className={panelStyles.createTemplateSelect}
value={selectedTemplateId}
onChange={e => handleTemplateSelect(e.target.value)}
disabled={isSubmitting}
>
<option value="">{t('Vorlage wählen')}</option>
{userRoleTemplates.map(template => {
const alreadyInMandate = existingMandateRoleLabels.has(template.roleLabel);
return (
<option key={template.id} value={template.id}>
{template.roleLabel}
{template.isSystemRole ? ` (${t('System-Template')})` : ''}
{template.scopeType === 'global' && !template.isSystemRole
? ` (${t('Template')})`
: ''}
{alreadyInMandate ? `${t('bereits vorhanden')}` : ''}
</option>
);
})}
</select>
<p className={panelStyles.createTemplateHint}>
{userRoleTemplates.length === 0
? t('Keine Vorlagen verfügbar.')
: t(
'Berechtigungen (AccessRules) der Vorlage werden in die neue Mandanten-Rolle übernommen. Bezeichnung und Beschreibung können angepasst werden.',
)}
</p>
</div>
<FormGeneratorForm
key={selectedTemplateId || 'no-template'}
attributes={createFields}
data={createFormData as { roleLabel: string; description?: Record<string, string> }}
mode="create"
onSubmit={handleCreateRoleFromTemplate}
onCancel={closeCreateModal}
submitButtonText={isSubmitting ? t('Erstelle…') : t('Rolle aus Vorlage erstellen')}
cancelButtonText={t('Abbrechen')}
/>
</>
)}
</Popup>
)}
{editingRole && (
<Popup
isOpen={true}
title={`${t('Rolle bearbeiten')}: ${editingRole.roleLabel}`}
onClose={() => !isSubmitting && setEditingRole(null)}
size="medium"
closable={!isSubmitting}
>
{editFields.length === 0 ? (
<div className={adminStyles.loadingContainer}>
<div className={adminStyles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<>
<div
className={`${adminStyles.infoBox} ${panelStyles.panelInfoBox}`}
style={{ marginBottom: '1rem' }}
>
<FaUserShield className={panelStyles.panelInfoBoxIcon} style={{ marginRight: 8 }} />
<span className={panelStyles.panelInfoBoxText}>
{t('Geltungsbereich')}:{' '}
<strong>
{editingRole.mandateId ? t('Mandanten-Instanz') : t('Template (global)')}
</strong>{' '}
({t('kann nicht geändert werden')})
</span>
</div>
<FormGeneratorForm
attributes={editFields}
data={editingRole}
mode="edit"
onSubmit={handleEditRole}
onCancel={() => setEditingRole(null)}
submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
</>
)}
</Popup>
)}
</div>
);
};
export default MandateRolesPermissionsPanel;

View file

@ -0,0 +1,161 @@
.panel {
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.panelEmbedded {
width: 100%;
max-width: 100%;
min-width: 0;
padding: 0;
box-sizing: border-box;
}
.panelInDashboard {
height: 100%;
min-height: 280px;
}
.cardInDashboard {
border: none;
border-radius: 0;
box-shadow: none;
height: 100%;
min-height: 0;
}
.card {
display: flex;
flex-direction: column;
min-width: 0;
width: 100%;
}
.cardEmbedded {
width: 100%;
max-width: 100%;
border: 1px solid var(--color-border, #e2e8f0);
border-radius: 8px;
background: var(--color-bg, #fff);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
overflow: hidden;
box-sizing: border-box;
}
.headerBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
padding: 8px 12px;
background: var(--bg-secondary, #f8fafc);
border-bottom: 1px solid var(--color-border, #e2e8f0);
}
.headerBarEmbedded {
padding: 6px 10px;
}
.headerTitle {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-primary, #0f172a);
letter-spacing: 0.01em;
white-space: nowrap;
}
.headerActions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex-wrap: wrap;
}
.btnCompactPrimary,
.btnCompactSecondary {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
font-size: 0.75rem;
font-weight: 500;
border-radius: 5px;
cursor: pointer;
transition: background 0.15s, opacity 0.15s;
}
.btnCompactPrimary {
background: var(--primary-color, #f25843);
color: #fff;
border: none;
}
.btnCompactPrimary:hover:not(:disabled) {
background: var(--primary-dark, #d94d3a);
}
.btnCompactPrimary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btnCompactSecondary {
background: var(--color-bg, #fff);
color: var(--text-primary, #0f172a);
border: 1px solid var(--color-border, #e2e8f0);
}
.btnCompactSecondary:hover:not(:disabled) {
background: var(--bg-secondary, #f8fafc);
}
.btnCompactSecondary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.tableWrap {
min-height: 200px;
max-height: 420px;
display: flex;
flex-direction: column;
}
.tableWrapEmbedded {
min-height: 200px;
height: 240px;
max-height: 280px;
width: 100%;
min-width: 0;
overflow: hidden;
}
.tableWrap :global(.formGeneratorTable) {
height: 100%;
max-height: 420px;
min-width: 0;
}
.tableWrapEmbedded :global(.formGeneratorTable) {
height: 100%;
min-height: 180px;
max-height: 100%;
font-size: 12px;
min-width: 0;
}
.tableWrapEmbedded :global(.formGeneratorTable .tableWrapper) {
border: none;
border-radius: 0;
box-shadow: none;
min-width: 0;
}
.tableWrapEmbedded :global(.formGeneratorTable .tableContainer) {
min-height: 120px;
}

View file

@ -0,0 +1,392 @@
/**
* MandateUsersPanel manage users within a single mandate (members, roles, add/remove).
* Used in AdminMandatesPage expanded row (MandateExpandDashboard).
*/
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import {
useUserMandates,
type MandateUser,
type Role,
type PaginationParams,
} from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../FormGenerator/FormGeneratorForm';
import type { ColumnConfig } from '../FormGenerator/FormGeneratorTable';
import { FaPlus, FaSync } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import { InlineRoleMultiselect } from './InlineRoleMultiselect';
import adminStyles from '../../pages/admin/Admin.module.css';
import panelStyles from './MandateUsersPanel.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export interface MandateUsersPanelProps {
mandateId: string;
embedded?: boolean;
/** Inside MandateExpandDashboard — no nested card border */
embeddedInDashboard?: boolean;
}
export const MandateUsersPanel: React.FC<MandateUsersPanelProps> = ({
mandateId,
embedded = false,
embeddedInDashboard = false,
}) => {
const { t } = useLanguage();
const { showError, showSuccess } = useToast();
const { request } = useApiRequest();
const {
users,
loading,
pagination,
fetchMandateUsers,
addUserToMandate,
removeUserFromMandate,
updateUserRoles,
fetchRoles,
fetchAllUsers,
} = useUserMandates();
const currentMandateIdRef = useRef(mandateId);
currentMandateIdRef.current = mandateId;
const [roles, setRoles] = useState<Role[]>([]);
const [allUsers, setAllUsers] = useState<
Array<{ id: string; username: string; email?: string; fullName?: string }>
>([]);
const [showAddModal, setShowAddModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
const [savingUserId, setSavingUserId] = useState<string | null>(null);
useEffect(() => {
fetchAttributes(request, 'UserMandateView')
.then(setBackendAttributes)
.catch(() => setBackendAttributes([]));
}, [request]);
useEffect(() => {
fetchAllUsers().then(setAllUsers);
}, [fetchAllUsers]);
useEffect(() => {
if (!mandateId) return;
currentMandateIdRef.current = mandateId;
fetchMandateUsers(mandateId);
fetchRoles(mandateId).then(setRoles);
}, [mandateId, fetchMandateUsers, fetchRoles]);
const refetchWithParams = useCallback(
async (paginationParams?: PaginationParams) => {
const id = currentMandateIdRef.current;
if (!id) return;
if (paginationParams && Object.keys(paginationParams).length > 0) {
return fetchMandateUsers(paginationParams);
}
return fetchMandateUsers(id);
},
[fetchMandateUsers],
);
const availableUsers = useMemo(() => {
const existingUserIds = new Set(users.map((u) => u.userId));
return allUsers.filter((u) => !existingUserIds.has(u.id));
}, [allUsers, users]);
const roleOptions = useMemo(
() =>
roles
.filter((r) => !r.featureInstanceId && !r.featureCode)
.map((r) => ({
value: r.id,
label: r.roleLabel || r.id,
})),
[roles],
);
const handleRoleChange = useCallback(
async (userId: string, roleIds: string[]) => {
if (!mandateId) return;
setSavingUserId(userId);
try {
const result = await updateUserRoles(mandateId, userId, roleIds);
if (!result.success) {
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Rollen'));
await fetchMandateUsers(mandateId);
}
} finally {
setSavingUserId(null);
}
},
[mandateId, updateUserRoles, showError, t, fetchMandateUsers],
);
const _rawColumns: ColumnConfig[] = useMemo(() => {
const cols: ColumnConfig[] = [
{
key: 'username',
label: t('Benutzername'),
sortable: true,
filterable: true,
searchable: true,
width: embedded ? 100 : 140,
},
{
key: 'email',
label: t('E-Mail'),
sortable: true,
filterable: true,
searchable: true,
width: embedded ? 130 : 180,
},
];
if (!embedded) {
cols.push({
key: 'fullName',
label: t('Vollständiger Name'),
sortable: true,
filterable: true,
searchable: true,
width: 160,
});
}
cols.push(
{
key: 'roleIds',
label: t('Rollen'),
sortable: false,
filterable: false,
searchable: false,
width: embedded ? 150 : 220,
formatter: (_value: unknown, row: MandateUser) => (
<InlineRoleMultiselect
value={row.roleIds ?? []}
options={roleOptions}
onChange={(ids) => handleRoleChange(row.userId, ids)}
loading={savingUserId === row.userId}
/>
),
},
{
key: 'enabled',
label: t('Aktiv'),
sortable: true,
filterable: true,
searchable: false,
width: embedded ? 52 : 72,
},
);
return cols;
}, [t, roleOptions, handleRoleChange, savingUserId, embedded]);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, backendAttributes),
[_rawColumns, backendAttributes],
);
const userOptions = useMemo(
() =>
availableUsers.map((u) => ({
value: u.id,
label: `${u.username} ${u.email ? `(${u.email})` : ''}`,
})),
[availableUsers],
);
const addUserFields: AttributeDefinition[] = useMemo(() => {
const userIdAttr = backendAttributes.find((a) => a.name === 'userId');
const roleIdsAttr = backendAttributes.find((a) => a.name === 'roleIds');
return [
{
name: 'targetUserId',
label: userIdAttr?.label || t('Benutzer'),
type: 'enum' as AttributeDefinition['type'],
required: true,
options: userOptions,
},
{
name: 'roleIds',
label: roleIdsAttr?.label || t('Rollen'),
type: 'multiselect' as AttributeDefinition['type'],
required: true,
options: roleOptions,
},
];
}, [userOptions, roleOptions, backendAttributes, t]);
const handleAddUser = async (data: { targetUserId: string; roleIds: string[] }) => {
if (!mandateId) return;
setIsSubmitting(true);
try {
const result = await addUserToMandate(mandateId, data);
if (result.success) {
setShowAddModal(false);
showSuccess(t('Hinzugefügt'), t('Benutzer wurde dem Mandanten zugewiesen.'));
fetchMandateUsers(mandateId);
} else {
showError(t('Fehler'), result.error || t('Fehler beim Hinzufügen des Benutzers'));
}
} finally {
setIsSubmitting(false);
}
};
const handleRemoveUser = async (user: MandateUser) => {
if (!mandateId) return;
const result = await removeUserFromMandate(mandateId, user.userId);
if (!result.success) {
showError(t('Fehler'), result.error || t('Fehler beim Entfernen des Benutzers'));
}
};
const refreshBtn = embedded ? (
<button
type="button"
className={panelStyles.btnCompactSecondary}
onClick={() => fetchMandateUsers(mandateId)}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
) : (
<button
type="button"
className={adminStyles.secondaryButton}
onClick={() => fetchMandateUsers(mandateId)}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
);
const addBtn = embedded ? (
<button
type="button"
className={panelStyles.btnCompactPrimary}
onClick={() => setShowAddModal(true)}
disabled={availableUsers.length === 0}
title={
availableUsers.length === 0
? t('Alle Benutzer sind bereits diesem Mandanten zugewiesen')
: undefined
}
>
<FaPlus /> {t('Benutzer hinzufügen')}
</button>
) : (
<button
type="button"
className={adminStyles.primaryButton}
onClick={() => setShowAddModal(true)}
disabled={availableUsers.length === 0}
title={
availableUsers.length === 0
? t('Alle Benutzer sind bereits diesem Mandanten zugewiesen')
: undefined
}
>
<FaPlus /> {t('Benutzer hinzufügen')}
</button>
);
return (
<div
className={`${panelStyles.panel} ${embedded ? panelStyles.panelEmbedded : ''} ${embeddedInDashboard ? panelStyles.panelInDashboard : ''}`}
>
<div
className={`${panelStyles.card} ${embedded && !embeddedInDashboard ? panelStyles.cardEmbedded : ''} ${embeddedInDashboard ? panelStyles.cardInDashboard : ''}`}
>
<div
className={`${panelStyles.headerBar} ${embedded ? panelStyles.headerBarEmbedded : ''}`}
>
<h3 className={panelStyles.headerTitle}>{t('Mandanten-Benutzer')}</h3>
<div className={panelStyles.headerActions}>
{refreshBtn}
{addBtn}
</div>
</div>
<div
className={`${panelStyles.tableWrap} ${embedded ? panelStyles.tableWrapEmbedded : ''}`}
>
<FormGeneratorTable
data={users}
columns={columns}
apiEndpoint={mandateId ? `/api/mandates/${mandateId}/users` : undefined}
loading={loading}
pagination
pageSize={embedded ? 10 : 25}
searchable
filterable
sortable
selectable={false}
compact
actionButtons={[
{
type: 'delete' as const,
title: t('Vom Mandat entfernen'),
},
]}
onDelete={handleRemoveUser}
hookData={{
refetch: refetchWithParams,
pagination,
handleDelete: async (userMandateId: string) => {
const user = users.find((u) => u.id === userMandateId);
if (user) {
const result = await removeUserFromMandate(mandateId, user.userId);
return result.success;
}
return false;
},
}}
emptyMessage={t('Keine Mitglieder gefunden')}
/>
</div>
</div>
{showAddModal && (
<div className={adminStyles.modalOverlay}>
<div className={adminStyles.modal}>
<div className={adminStyles.modalHeader}>
<h2 className={adminStyles.modalTitle}>{t('Benutzer zum Mandanten hinzufügen')}</h2>
<button
type="button"
className={adminStyles.modalClose}
onClick={() => setShowAddModal(false)}
>
</button>
</div>
<div className={adminStyles.modalContent}>
{availableUsers.length === 0 ? (
<p>{t('Alle Benutzer sind bereits diesem Mandanten zugewiesen.')}</p>
) : roleOptions.length === 0 ? (
<div className={adminStyles.loadingContainer}>
<div className={adminStyles.spinner} />
<span>{t('Lade Rollen')}</span>
</div>
) : (
<FormGeneratorForm
attributes={addUserFields}
mode="create"
onSubmit={handleAddUser}
onCancel={() => setShowAddModal(false)}
submitButtonText={isSubmitting ? t('Hinzufügen…') : t('Hinzufügen')}
cancelButtonText={t('Abbrechen')}
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default MandateUsersPanel;

View file

@ -17,7 +17,7 @@
import React from 'react';
import {
FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
FaCubes, FaEnvelopeOpenText, FaUsersCog, FaCube, FaShieldAlt,
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase,
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
@ -64,11 +64,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.admin.users': <FaUsers />,
'page.admin.invitations': <FaEnvelopeOpenText />,
'page.admin.mandates': <FaBuilding />,
'page.admin.roles': <FaKey />,
'page.admin.userRoleTemplates': <FaUserTag />,
'page.admin.role-permissions': <FaShieldAlt />,
'page.admin.mandateRolePermissions': <FaShieldAlt />,
'page.admin.user-mandates': <FaUserTag />,
'page.admin.userMandates': <FaUserTag />,
'page.admin.feature-roles': <FaCube />,
'page.admin.featureRoles': <FaCube />,
'page.admin.feature-instances': <FaCubes />,

View file

@ -18,6 +18,8 @@ export interface Role {
featureCode?: string;
isSystemRole?: boolean;
isTemplate?: boolean;
scopeType?: 'system' | 'global' | 'mandate';
userCount?: number;
createdAt?: number;
updatedAt?: number;
}
@ -276,12 +278,83 @@ export function useMandateRoles() {
return roles.filter(r => r.isTemplate === true);
}, [roles]);
/** Global + system user role templates (no mandateId).
* Used by AdminUserRoleTemplatesPage updates the shared `roles` state.
* NOTE: passes `undefined` as first arg so fetchRoles skips both branches and
* does NOT inherit currentMandateIdRef, ensuring no mandate header is sent. */
const fetchUserRoleTemplates = useCallback(
async (paginationParams?: PaginationParams): Promise<Role[]> => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return fetchRoles(undefined as any, { ...paginationParams });
},
[fetchRoles],
);
/** Fetch global/system templates without touching the shared `roles` state.
* Safe to call from components that already manage their own role list
* (e.g. MandateRolesPermissionsPanel) so the displayed roles aren't clobbered. */
const fetchTemplatesOnly = useCallback(async (): Promise<Role[]> => {
try {
const response = await api.get('/api/rbac/roles', {
params: { includeTemplates: 'false', scopeFilter: 'global' },
});
if (response.data?.items && Array.isArray(response.data.items)) {
return response.data.items as Role[];
}
if (Array.isArray(response.data)) {
return response.data as Role[];
}
return [];
} catch {
return [];
}
}, []);
/**
* Create a mandate role from a user role template (copies AccessRules).
*/
const createRoleFromTemplate = useCallback(
async (
templateRoleId: string,
targetMandateId: string,
data?: { roleLabel?: string; description?: Record<string, string> },
): Promise<{ success: boolean; data?: Role; error?: string }> => {
setLoading(true);
setError(null);
try {
const headers: Record<string, string> = { 'X-Mandate-Id': targetMandateId };
const response = await api.post(
'/api/rbac/roles/from-template',
{
templateRoleId,
mandateId: targetMandateId,
roleLabel: data?.roleLabel,
description: data?.description,
},
{ headers },
);
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage =
err.response?.data?.detail || err.message || 'Failed to create role from template';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
},
[],
);
return {
roles,
loading,
error,
pagination,
fetchRoles,
fetchUserRoleTemplates,
fetchTemplatesOnly,
createRoleFromTemplate,
getRole,
createRole,
updateRole,

View file

@ -30,6 +30,17 @@ import type { ColumnConfig } from '../components/FormGenerator/FormGeneratorTabl
// Re-export types
export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams };
/** List-table columns only; invoice/address fields stay in the edit form. */
const MANDATE_LIST_COLUMN_KEYS = ['name', 'label', 'enabled', 'mfaRequired', 'isSystem'] as const;
const MANDATE_LIST_COLUMN_WIDTHS: Record<string, number> = {
name: 120,
label: 200,
enabled: 88,
mfaRequired: 100,
isSystem: 110,
};
export interface AttributeDefinition {
name: string;
type: string;
@ -157,17 +168,21 @@ export function useAdminMandates() {
// Generate columns from attributes (types merged via resolveColumnTypes)
const columns: ColumnConfig[] = useMemo(() => {
const raw = attributes.map(attr => ({
key: attr.name,
label: attr.label || attr.name,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
displayField: (attr as any).displayField,
}));
const raw = MANDATE_LIST_COLUMN_KEYS.map((key) => {
const attr = attributes.find((a) => a.name === key);
if (!attr) return null;
return {
key: attr.name,
label: attr.label || attr.name,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: MANDATE_LIST_COLUMN_WIDTHS[attr.name] ?? attr.width ?? 120,
minWidth: attr.minWidth ?? 72,
maxWidth: attr.maxWidth ?? 240,
displayField: (attr as { displayField?: string }).displayField,
};
}).filter((c): c is NonNullable<typeof c> => c != null);
return resolveColumnTypes(raw, attributes);
}, [attributes]);

View file

@ -235,13 +235,14 @@ export function useUserMandates() {
* roles should be offered for user assignment - NOT global templates.
*/
const fetchRoles = useCallback(async (mandateId?: string): Promise<Role[]> => {
if (!mandateId) return [];
try {
const params: Record<string, string> = {};
if (mandateId) {
params.mandateId = mandateId;
params.scopeFilter = 'mandate';
}
const response = await api.get('/api/rbac/roles', { params });
const headers: Record<string, string> = { 'X-Mandate-Id': mandateId };
const params: Record<string, string> = {
mandateId,
includeTemplates: 'false',
};
const response = await api.get('/api/rbac/roles', { headers, params });
let roles: Role[] = [];
if (response.data?.items && Array.isArray(response.data.items)) {
roles = response.data.items;

View file

@ -413,9 +413,6 @@ export const AccessManagementHub: React.FC = () => {
<Link to="/admin/mandates" className={hubStyles.mandatesLink}>
<FaBuilding /> {t('Mandanten verwalten')}
</Link>
<Link to="/admin/user-mandates" className={hubStyles.mandatesLink}>
<FaUsers /> {t('Mandant-Benutzer')}
</Link>
</div>
{viewMode === 'hierarchy' ? (

View file

@ -65,7 +65,7 @@ export const AdminInvitationsPage: React.FC = () => {
.catch(() => setBackendAttributes([]));
}, [fetchMandates, request]);
// Load invitations and roles when mandate changes (same roles as AdminUserMandatesPage: user, viewer, admin)
// Load invitations and roles when mandate changes (same roles as MandateUsersPanel: user, viewer, admin)
useEffect(() => {
if (selectedMandateId) {
fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed });
@ -122,7 +122,7 @@ export const AdminInvitationsPage: React.FC = () => {
[_rawColumns, backendAttributes],
);
// Form attributes - same role options as AdminUserMandatesPage (user, viewer, admin)
// Form attributes - same role options as MandateUsersPanel (user, viewer, admin)
const createFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'token', 'sysCreatedBy', 'sysCreatedAt', 'sysUpdatedAt', 'sysUpdatedBy', 'expiresAt', 'currentUses', 'inviteUrl', 'featureInstanceId'];

View file

@ -1,603 +0,0 @@
/**
* AdminMandateRolePermissionsPage
*
* Admin page for managing access rules (permissions) for mandate-level roles.
* Similar to TrusteeInstanceRolesView but for mandate/global roles.
*
* Shows:
* - System roles (admin, user, viewer) - read-only permissions
* - Global roles (mandateId=null) - editable permissions
* - Mandate-specific roles (mandateId=xyz) - editable permissions
*
* Each role can be expanded to show/edit its AccessRules via AccessRulesEditor.
*
* Includes a "Cleanup Duplicates" tool to find and remove duplicate AccessRules.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useMandateRoles, type Role } from '../../hooks/useMandateRoles';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { AccessRulesEditor } from '../../components/AccessRules';
import api from '../../api';
import {
FaUserShield,
FaShieldAlt,
FaSync,
FaChevronDown,
FaChevronRight,
FaGlobe,
FaBuilding,
FaFilter,
FaBroom,
FaTimes,
FaExclamationTriangle,
FaCheckCircle
} from 'react-icons/fa';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
// Types for cleanup result
interface DuplicateGroup {
roleId: string;
context: string;
item: string;
totalCount: number;
keepId: string;
deleteCount: number;
deleteIds: string[];
}
interface CleanupResult {
totalRules: number;
uniqueSignatures: number;
duplicateGroups: number;
duplicateRulesToDelete: number;
deletedCount: number;
details: DuplicateGroup[];
}
interface TemplateFixDetail {
userMandateRoleId: string;
userMandateId: string;
mandateId: string;
templateRoleId: string;
templateRoleLabel: string;
instanceRoleId?: string;
action: string;
}
interface TemplateFixResult {
totalUserMandateRoles: number;
invalidAssignments: number;
fixedCount: number;
details: TemplateFixDetail[];
}
export const AdminMandateRolePermissionsPage: React.FC = () => {
const { t } = useLanguage();
const {
roles,
loading,
error,
fetchRoles,
} = useMandateRoles();
const { fetchMandates } = useUserMandates();
// State
const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
// Cleanup state
const [showCleanupModal, setShowCleanupModal] = useState(false);
const [cleanupLoading, setCleanupLoading] = useState(false);
const [cleanupResult, setCleanupResult] = useState<CleanupResult | null>(null);
const [templateFixResult, setTemplateFixResult] = useState<TemplateFixResult | null>(null);
const [cleanupError, setCleanupError] = useState<string | null>(null);
const [cleanupPhase, setCleanupPhase] = useState<'idle' | 'preview' | 'done'>('idle');
// Load mandates on mount
useEffect(() => {
const loadMandates = async () => {
const data = await fetchMandates();
setMandates(data);
if (data.length > 0 && !selectedMandateId) {
setSelectedMandateId(data[0].id);
}
};
loadMandates();
}, [fetchMandates]);
// Load roles when mandate or scopeFilter changes
useEffect(() => {
if (selectedMandateId) {
fetchRoles(selectedMandateId, { scopeFilter });
}
}, [selectedMandateId, scopeFilter, fetchRoles]);
// Refetch roles
const handleRefresh = useCallback(async () => {
if (selectedMandateId) {
await fetchRoles(selectedMandateId, { scopeFilter });
}
}, [selectedMandateId, scopeFilter, fetchRoles]);
const getTextValue = (value: string | Record<string, string> | undefined): string => {
if (!value) return '';
if (typeof value === 'string') return value;
return Object.values(value).find(v => !!v) || '';
};
// Toggle role expansion
const toggleRole = (roleId: string) => {
setExpandedRoleId(prev => prev === roleId ? null : roleId);
};
// Check if a role is a template (not bound to a specific mandate)
const _isTemplateRole = (role: Role): boolean => {
return !!role.isSystemRole || !role.mandateId;
};
// Get scope badge
const getScopeBadge = (role: Role) => {
if (role.isSystemRole) {
return (
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
<FaUserShield style={{ marginRight: 4 }} /> {t('System-Template')}
</span>
);
}
if (!role.mandateId) {
return (
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
<FaGlobe style={{ marginRight: 4 }} /> {t('Template')}
</span>
);
}
return (
<span className={styles.badge} style={{ background: 'var(--success-color, #38a169)', color: 'white' }}>
<FaBuilding style={{ marginRight: 4 }} /> {t('Mandant')}
</span>
);
};
// --- Cleanup functions ---
const _openCleanupModal = useCallback(async () => {
setShowCleanupModal(true);
setCleanupError(null);
setCleanupResult(null);
setTemplateFixResult(null);
setCleanupPhase('idle');
setCleanupLoading(true);
try {
const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=true');
const data = response.data;
setCleanupResult(data.duplicateRules || data);
setTemplateFixResult(data.templateRoleAssignments || null);
setCleanupPhase('preview');
} catch (err: any) {
setCleanupError(err?.response?.data?.detail || err?.message || t('Fehler beim Laden der Duplikate'));
} finally {
setCleanupLoading(false);
}
}, [t]);
const _executeCleanup = useCallback(async () => {
setCleanupLoading(true);
setCleanupError(null);
try {
const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=false');
const data = response.data;
setCleanupResult(data.duplicateRules || data);
setTemplateFixResult(data.templateRoleAssignments || null);
setCleanupPhase('done');
// Refresh roles after cleanup
if (selectedMandateId) {
fetchRoles(selectedMandateId, { scopeFilter });
}
} catch (err: any) {
setCleanupError(err?.response?.data?.detail || err?.message || t('Fehler beim Bereinigen'));
} finally {
setCleanupLoading(false);
}
}, [selectedMandateId, scopeFilter, fetchRoles, t]);
const _closeCleanupModal = useCallback(() => {
setShowCleanupModal(false);
setCleanupResult(null);
setCleanupError(null);
setCleanupPhase('idle');
}, []);
// Filter options for scope
const scopeOptions = useMemo(() => [
{ value: 'mandate', label: t('Mandanten-Rollen') },
{ value: 'all', label: t('Alle inkl. Templates') },
{ value: 'global', label: t('Nur Templates') },
], [t]);
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler beim Laden')}: {error}
</p>
<button className={styles.secondaryButton} onClick={handleRefresh}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
{/* Header */}
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
{t('Rollen-Berechtigungen')}
</h1>
<p className={styles.pageSubtitle}>
{t('Verwalten Sie die Zugriffsrechte für Mandanten- und globale Rollen')}
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={_openCleanupModal}
disabled={loading}
title={t('Doppelte Regeln finden und bereinigen')}
>
<FaBroom /> {t('Duplikate bereinigen')}
</button>
<button
className={styles.secondaryButton}
onClick={handleRefresh}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
</div>
</div>
{/* Filters */}
<div className={styles.filterBar}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Mandant')}</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
{mandates.map(mandate => (
<option key={mandate.id} value={mandate.id}>
{mandateDisplayLabel(mandate)}
</option>
))}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaFilter style={{ marginRight: 4 }} /> {t('Bereich')}:
</label>
<select
className={styles.filterSelect}
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
>
{scopeOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* Info Box */}
<div className={styles.infoBox}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
<span>
{t('Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.')}{' '}
<strong>{t('Template-Rollen')}</strong>{' '}
{t('sind schreibgeschützt Änderungen an Templates wirken sich nur auf neu erstellte Mandanten aus.')}{' '}
<strong>{t('Mandanten-Rollen')}</strong> {t('sind direkt bearbeitbar.')}
</span>
</div>
{/* Loading State */}
{loading && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Rollen')}</span>
</div>
)}
{/* Empty State */}
{!loading && roles.length === 0 && (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<p>{t('Keine Rollen gefunden')}</p>
<p className={styles.emptyHint}>
{scopeFilter === 'mandate'
? t('Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.')
: scopeFilter === 'global'
? t('Es gibt noch keine Rollentemplates')
: t('Es gibt noch keine Rollen')}
</p>
</div>
)}
{/* Roles List */}
{!loading && roles.length > 0 && (
<div className={styles.rolesList}>
{roles.map(role => (
<div key={role.id} className={styles.roleCard}>
{/* Role Header - Clickable to expand */}
<div
className={styles.roleHeader}
onClick={() => toggleRole(role.id)}
>
<div className={styles.roleInfo}>
<span className={styles.expandIcon}>
{expandedRoleId === role.id ? <FaChevronDown /> : <FaChevronRight />}
</span>
<span className={styles.roleLabel}>{role.roleLabel}</span>
<span className={styles.roleDescription}>
{getTextValue(role.description)}
</span>
</div>
<div className={styles.roleBadges}>
{getScopeBadge(role)}
</div>
</div>
{/* Expanded Content - AccessRulesEditor */}
{expandedRoleId === role.id && (
<div className={styles.roleContent}>
{_isTemplateRole(role) && (
<div className={styles.infoBox} style={{ marginBottom: '0.75rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaUserShield style={{ marginRight: '0.5rem', color: 'var(--warning-color, #d69e2e)' }} />
<span>
{t('Dies ist eine')} <strong>{t('Template-Rolle')}</strong>.{' '}
{t('Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus. Bestehende Mandanten-Instanzen werden nicht aktualisiert.')}
</span>
</div>
)}
<AccessRulesEditor
roleId={role.id}
roleName={role.roleLabel}
isTemplate={_isTemplateRole(role)}
readOnly={false}
apiBasePath="/api/rbac"
mandateId={selectedMandateId}
/>
</div>
)}
</div>
))}
</div>
)}
{/* Cleanup Duplicates Modal */}
{showCleanupModal && (
<div className={styles.modalOverlay}>
<div className={styles.modal} style={{ maxWidth: '750px' }}>
<div className={styles.modalHeader}>
<h3 className={styles.modalTitle}>
<FaBroom style={{ marginRight: '0.5rem' }} />
{t('Doppelte Regeln bereinigen')}
</h3>
<button className={styles.modalClose} onClick={_closeCleanupModal}>
<FaTimes />
</button>
</div>
<div className={styles.modalContent}>
{/* Loading */}
{cleanupLoading && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{cleanupPhase === 'idle' ? t('Analysiere Duplikate') : t('Bereinige Duplikate')}</span>
</div>
)}
{/* Error */}
{cleanupError && (
<div style={{ padding: '1rem', background: '#fed7d7', borderRadius: '6px', color: '#c53030', marginBottom: '1rem' }}>
<FaExclamationTriangle style={{ marginRight: '0.5rem' }} />
{cleanupError}
</div>
)}
{/* Results */}
{cleanupResult && !cleanupLoading && (
<>
{/* Summary Cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem', marginBottom: '1.25rem' }}>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{cleanupResult.totalRules}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Regeln total')}</div>
</div>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{cleanupResult.uniqueSignatures}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Eindeutige Regeln')}</div>
</div>
<div style={{ padding: '0.75rem', background: cleanupResult.duplicateGroups > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateGroups > 0 ? '#fc8181' : '#9ae6b4'}` }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: cleanupResult.duplicateGroups > 0 ? '#c53030' : '#2f855a' }}>{cleanupResult.duplicateGroups}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Duplikat-Gruppen')}</div>
</div>
<div style={{ padding: '0.75rem', background: cleanupResult.duplicateRulesToDelete > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateRulesToDelete > 0 ? '#fc8181' : '#9ae6b4'}` }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: cleanupResult.duplicateRulesToDelete > 0 ? '#c53030' : '#2f855a' }}>
{cleanupPhase === 'done' ? cleanupResult.deletedCount : cleanupResult.duplicateRulesToDelete}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
{cleanupPhase === 'done' ? t('Gelöscht') : t('Zu löschen')}
</div>
</div>
</div>
{/* Status Message */}
{cleanupPhase === 'done' && (
<div style={{ padding: '0.75rem 1rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span><strong>{cleanupResult.deletedCount}</strong> {t('Doppelte Regeln wurden erfolgreich entfernt')}</span>
</div>
)}
{cleanupPhase === 'preview' && cleanupResult.duplicateGroups === 0 && (
<div style={{ padding: '0.75rem 1rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span>{t('Keine Duplikate gefunden, alles sauber')}</span>
</div>
)}
{/* Details Table */}
{cleanupResult.details && cleanupResult.details.length > 0 && (
<div style={{ marginTop: '0.5rem' }}>
<h4 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-secondary)' }}>
{t('Duplikat-Details')}{' '}
{cleanupResult.details.length < cleanupResult.duplicateGroups &&
`(${cleanupResult.details.length} ${t('von')} ${cleanupResult.duplicateGroups})`}
</h4>
<div style={{ maxHeight: '300px', overflowY: 'auto', border: '1px solid var(--border-color)', borderRadius: '6px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
<thead>
<tr style={{ background: 'var(--bg-secondary)', position: 'sticky', top: 0 }}>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Kontext')}</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Item')}</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Total')}</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)', color: '#c53030' }}>{t('Duplikate')}</th>
</tr>
</thead>
<tbody>
{cleanupResult.details.map((group, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{group.context}
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{group.item}
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center' }}>{group.totalCount}</td>
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center', color: '#c53030', fontWeight: 600 }}>{group.deleteCount}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Template Role Assignments Section */}
{templateFixResult && templateFixResult.invalidAssignments > 0 && (
<div style={{ marginTop: '1.25rem', borderTop: '1px solid var(--border-color)', paddingTop: '1rem' }}>
<h4 style={{ fontSize: '0.9375rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text-primary)' }}>
{t('Template-Rollen-Zuweisungen')}
</h4>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem', marginBottom: '1rem' }}>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{templateFixResult.totalUserMandateRoles}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Rollenzuweisungen total')}</div>
</div>
<div style={{ padding: '0.75rem', background: templateFixResult.invalidAssignments > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${templateFixResult.invalidAssignments > 0 ? '#fc8181' : '#9ae6b4'}` }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: templateFixResult.invalidAssignments > 0 ? '#c53030' : '#2f855a' }}>{templateFixResult.invalidAssignments}</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Template statt Instanz')}</div>
</div>
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: templateFixResult.fixedCount > 0 ? '#2f855a' : 'var(--text-primary)' }}>
{cleanupPhase === 'done' ? templateFixResult.fixedCount : templateFixResult.invalidAssignments}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
{cleanupPhase === 'done' ? t('Repariert') : t('Zu reparieren')}
</div>
</div>
</div>
{templateFixResult.details && templateFixResult.details.length > 0 && (
<div style={{ maxHeight: '200px', overflowY: 'auto', border: '1px solid var(--border-color)', borderRadius: '6px' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
<thead>
<tr style={{ background: 'var(--bg-secondary)', position: 'sticky', top: 0 }}>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Rolle')}</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Mandant')}</th>
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Aktion')}</th>
</tr>
</thead>
<tbody>
{templateFixResult.details.map((detail, idx) => (
<tr key={idx} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{detail.templateRoleLabel}
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem' }}>
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
{detail.mandateId.substring(0, 8)}...
</span>
</td>
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center' }}>
<span style={{
fontSize: '0.75rem',
padding: '0.125rem 0.5rem',
borderRadius: '10px',
background: detail.action.includes('replace') ? '#ebf8ff' : detail.action.includes('delete') ? '#fff5f5' : '#f7fafc',
color: detail.action.includes('replace') ? '#2b6cb0' : detail.action.includes('delete') ? '#c53030' : '#718096',
}}>
{detail.action}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{templateFixResult && templateFixResult.invalidAssignments === 0 && (
<div style={{ marginTop: '1rem', padding: '0.5rem 0.75rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', fontSize: '0.875rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
<FaCheckCircle />
<span>{t('Keine fehlerhaften Templaterollenzuweisungen')}</span>
</div>
)}
</>
)}
</div>
<div className={styles.modalFooter}>
<button className={styles.secondaryButton} onClick={_closeCleanupModal}>
{cleanupPhase === 'done' ? t('Schließen') : t('Abbrechen')}
</button>
{cleanupPhase === 'preview' && cleanupResult && (cleanupResult.duplicateRulesToDelete > 0 || (templateFixResult && templateFixResult.invalidAssignments > 0)) && (
<button
className={styles.dangerButton}
onClick={_executeCleanup}
disabled={cleanupLoading}
>
<FaBroom /> {t('Bereinigung ausführen')}
</button>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminMandateRolePermissionsPage;

View file

@ -1,486 +0,0 @@
/**
* AdminMandateRolesPage
*
* Admin page for managing ALL ROLES (system + global + mandate-specific).
* Consolidated view replacing separate System-Roles page.
*
* Shows:
* - System roles (admin, user, viewer) - read-only, cannot be deleted
* - Global roles (mandateId=null) - CRUD available
* - Mandate-specific roles (mandateId=xyz) - CRUD available
* - Feature-template roles are managed in AdminFeatureRolesPage
*
* ALL filtering, sorting, and pagination is handled by the backend.
*/
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMandateRoles, type Role, type RoleCreate, type RoleUpdate, type PaginationParams } from '../../hooks/useMandateRoles';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaShieldAlt, FaCube } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
export const AdminMandateRolesPage: React.FC = () => {
const { t, currentLanguage } = useLanguage();
const navigate = useNavigate();
const { request } = useApiRequest();
const { showError, showWarning } = useToast();
const {
roles,
loading,
error,
pagination,
fetchRoles,
createRole,
updateRole,
deleteRole,
} = useMandateRoles();
const { fetchMandates } = useUserMandates();
// State
const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
// Store current filter state for refetch
const currentScopeFilterRef = useRef(scopeFilter);
currentScopeFilterRef.current = scopeFilter;
// Load mandates and attributes on mount
useEffect(() => {
const loadMandates = async () => {
const data = await fetchMandates();
setMandates(data);
if (data.length > 0 && !selectedMandateId) {
setSelectedMandateId(data[0].id);
}
};
loadMandates();
fetchAttributes(request, 'RoleView')
.then(setBackendAttributes)
.catch(() => setBackendAttributes([]));
}, [fetchMandates, request]);
// Load roles when mandate or scopeFilter changes
useEffect(() => {
if (selectedMandateId) {
fetchRoles(selectedMandateId, { scopeFilter });
}
}, [selectedMandateId, scopeFilter, fetchRoles]);
// Refetch wrapper that accepts pagination params from FormGeneratorTable
// and includes the current mandateId and scopeFilter
const refetchWithParams = useCallback(async (paginationParams?: PaginationParams) => {
if (!selectedMandateId) return;
// Merge pagination params with current filter state
return fetchRoles(selectedMandateId, {
...paginationParams,
scopeFilter: currentScopeFilterRef.current
});
}, [selectedMandateId, fetchRoles]);
const getDescriptionText = (desc: any) => {
if (!desc) return '-';
if (typeof desc === 'string') return desc;
if (typeof desc === 'object') {
return desc[currentLanguage] || desc['xx'] || Object.values(desc).find((v: any) => typeof v === 'string' && v.trim()) || '-';
}
return String(desc);
};
const _rawColumns: ColumnConfig[] = useMemo(() => [
{ key: 'roleLabel', sortable: true, filterable: true, searchable: true, width: 150 },
{
key: 'description',
sortable: false,
filterable: false,
width: 250,
formatter: (value: string) => getDescriptionText(value),
},
{ key: 'scopeType', sortable: true, filterable: true, width: 160 },
{ key: 'userCount', sortable: true, filterable: true, width: 100 },
], []);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, backendAttributes),
[_rawColumns, backendAttributes],
);
// Form attributes from backend - for create form
const createFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
const fields = backendAttributes
.filter(attr => !excludedFields.includes(attr.name))
.map(attr => ({ ...attr })) as AttributeDefinition[];
// Add scope field for mandate/global selection (not a model attribute)
if (fields.length > 0) {
fields.push({
name: 'scope',
label: t('Geltungsbereich'),
type: 'enum' as any,
required: true,
default: 'mandate',
options: [
{ value: 'mandate', label: t('Nur dieser Mandant') },
{ value: 'global', label: t('Template bei neuen Mandanten') },
]
});
}
return fields;
}, [backendAttributes, t]);
// Form attributes from backend - for edit form
// NOTE: mandateId/featureInstanceId/featureCode are IMMUTABLE - only description can be edited
const editFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
const fields = backendAttributes
.filter(attr => !excludedFields.includes(attr.name))
.map(attr => ({
...attr,
// Mark roleLabel as readonly (cannot change after creation)
readonly: attr.name === 'roleLabel' ? true : attr.readonly,
})) as AttributeDefinition[];
// No scope field for edit - context is immutable!
return fields;
}, [backendAttributes]);
// Handle create role
const handleCreateRole = async (data: { roleLabel: string; description?: Record<string, string>; scope: 'mandate' | 'global' }) => {
if (!selectedMandateId) return;
setIsSubmitting(true);
try {
const roleData: RoleCreate = {
roleLabel: data.roleLabel.toLowerCase().replace(/\s+/g, '_'),
description: data.description,
mandateId: data.scope === 'mandate' ? selectedMandateId : undefined
};
const result = await createRole(roleData, selectedMandateId);
if (result.success) {
setShowCreateModal(false);
await fetchRoles(selectedMandateId, { scopeFilter });
} else {
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Rolle'));
}
} catch (err: any) {
console.error('Create role error:', err);
showError(t('Fehler'), err.message || t('Fehler beim Erstellen der Rolle'));
} finally {
setIsSubmitting(false);
}
};
// Handle edit role
const handleEditRole = async (data: RoleUpdate & { scope?: 'mandate' | 'global' }) => {
if (!editingRole) return;
setIsSubmitting(true);
try {
// Convert scope to mandateId - NOTE: Context fields are IMMUTABLE per concept!
// We should not be changing mandateId after creation
const updateData: RoleUpdate = {
roleLabel: data.roleLabel,
description: data.description,
// mandateId is immutable - don't include in update
};
const result = await updateRole(editingRole.id, updateData);
if (result.success) {
setEditingRole(null);
if (selectedMandateId) {
await fetchRoles(selectedMandateId, { scopeFilter });
}
} else {
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Rolle'));
}
} catch (err: any) {
console.error('Update role error:', err);
showError(t('Fehler'), err.message || t('Fehler beim Aktualisieren der Rolle'));
} finally {
setIsSubmitting(false);
}
};
// Handle delete role (confirmation handled by DeleteActionButton)
const handleDeleteRole = async (role: Role) => {
if (role.isSystemRole) {
showWarning(t('Nicht erlaubt'), t('System-Rollen können nicht gelöscht werden.'));
return;
}
const result = await deleteRole(role.id);
if (result.success) {
// Refetch to update the list
await fetchRoles(selectedMandateId, { scopeFilter });
} else {
showError(t('Fehler'), result.error || t('Fehler beim Löschen der Rolle'));
}
};
// Handle edit click
const handleEditClick = (role: Role) => {
setEditingRole(role);
};
if (error && !selectedMandateId) {
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Rollen')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie systemweite und globale')}</p>
</div>
<div className={styles.headerActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/mandate-role-permissions')}
>
<FaShieldAlt /> {t('Rollen-Berechtigungen')}
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/feature-roles')}
>
<FaCube /> {t('Feature Rollen & Rechte')}
</button>
</div>
</div>
{/* Mandate Selector and Filters */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
{t('Mandant')}:
</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">{t('Mandant wählen')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{mandateDisplayLabel(m)}
</option>
))}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>{t('Filter')}</label>
<select
className={styles.filterSelect}
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
style={{ minWidth: 150 }}
>
<option value="mandate">{t('Mandanten-Rollen')}</option>
<option value="all">{t('Alle inkl. Templates')}</option>
<option value="global">{t('Nur Templates')}</option>
</select>
</div>
{selectedMandateId && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchRoles(selectedMandateId, { scopeFilter })}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neue Rolle')}
</button>
</div>
)}
</div>
{/* Info Box */}
{selectedMandateId && (
<div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
<strong>{t('System-Templates')}</strong>{' '}
{t('(admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert. Templates selbst können nicht gelöscht werden.')}{' '}
<strong>{t('Mandanten-Rollen')}</strong>{' '}
{t('gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.')}
</span>
</div>
)}
{/* Content */}
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten aus, um dessen Rollen zu verwalten.')}
</p>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={roles}
columns={columns}
apiEndpoint="/api/rbac/roles"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
actionButtons={[
{
type: 'edit' as const,
onAction: handleEditClick,
title: t('Rolle bearbeiten'),
disabled: (row: Role) => row.isSystemRole ? { disabled: true, message: t('System-Rollen können nicht bearbeitet werden') } : false
},
{
type: 'delete' as const,
title: t('Rolle löschen'),
disabled: (row: Role) => row.isSystemRole ? { disabled: true, message: t('System-Rollen können nicht gelöscht werden') } : false
}
]}
onDelete={handleDeleteRole}
hookData={{
refetch: refetchWithParams,
pagination: pagination,
handleDelete: handleDeleteRole,
}}
emptyMessage={t('Keine Rollen gefunden')}
/>
</div>
)}
{/* Create Role Modal */}
{showCreateModal && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neue Rolle erstellen')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowCreateModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
{createFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<FormGeneratorForm
attributes={createFields}
mode="create"
onSubmit={handleCreateRole}
onCancel={() => setShowCreateModal(false)}
submitButtonText={isSubmitting ? t('Erstelle…') : t('Rolle erstellen')}
cancelButtonText={t('Abbrechen')}
/>
)}
</div>
</div>
</div>
)}
{/* Edit Role Modal */}
{editingRole && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{t('Rolle bearbeiten')}: {editingRole.roleLabel}
</h2>
<button
className={styles.modalClose}
onClick={() => setEditingRole(null)}
>
</button>
</div>
<div className={styles.modalContent}>
{editFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<>
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
{t('Geltungsbereich')}:{' '}
<strong>
{editingRole.mandateId ? t('Mandanten-Instanz') : t('Template (global)')}
</strong>{' '}
({t('kann nicht geändert werden')})
</span>
</div>
<FormGeneratorForm
attributes={editFields}
data={editingRole}
mode="edit"
onSubmit={handleEditRole}
onCancel={() => setEditingRole(null)}
submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
</>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminMandateRolesPage;

View file

@ -4,21 +4,17 @@
* Admin page for managing Mandates (tenants) using FormGeneratorTable.
*/
import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import React, { useState, useCallback } from 'react';
import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates';
import { useApiRequest } from '../../hooks/useApi';
import { fetchSettingsAdmin, updateSettingsAdmin } from '../../api/billingApi';
import {
mergeBillingIntoMandateFormData,
splitMandateAndBillingFromForm,
} from '../../utils/mandateBillingFormMerge';
import { updateSettingsAdmin } from '../../api/billingApi';
import { splitMandateAndBillingFromForm } from '../../utils/mandateBillingFormMerge';
import { useToast } from '../../contexts/ToastContext';
import { usePrompt } from '../../hooks/usePrompt';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
import { getUserDataCache } from '../../utils/userCache';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { MandateExpandDashboard } from '../../components/admin/MandateExpandDashboard';
import { FaPlus, FaSync, FaSkullCrossbones } from 'react-icons/fa';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
@ -26,7 +22,6 @@ import { useLanguage } from '../../providers/language/LanguageContext';
export const AdminMandatesPage: React.FC = () => {
const { t } = useLanguage();
const navigate = useNavigate();
const { request } = useApiRequest();
const { showWarning, showSuccess } = useToast();
const { prompt, PromptDialog } = usePrompt();
@ -48,52 +43,42 @@ export const AdminMandatesPage: React.FC = () => {
} = useAdminMandates();
const {
formAttributes,
createFormAttributes,
formAttributesWithBilling,
createFormAttributesWithBilling,
loading: mandateAttrsLoading,
} = useMandateFormAttributes();
const [showCreateModal, setShowCreateModal] = useState(false);
/** Mandate row merged with billing fields for FormGenerator */
const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null);
const [editingBillingWarning, setEditingBillingWarning] = useState<string | null>(null);
const [expandedMandateIds, setExpandedMandateIds] = useState<Set<string>>(new Set());
const [detailRefreshKey, setDetailRefreshKey] = useState(0);
const isPlatformAdmin = getUserDataCache()?.isPlatformAdmin === true;
const isMandateExpanded = useCallback(
(mandate: Mandate) => expandedMandateIds.has(mandate.id),
[expandedMandateIds],
);
// MandateAdmin: only label + billing fields editable; rest readonly
const _MANDATE_ADMIN_EDITABLE = new Set(['label', 'warningThresholdPercent', 'notifyOnWarning', 'notifyEmails']);
const editFormAttrs: AttributeDefinition[] = useMemo(() => {
if (isPlatformAdmin) return formAttributesWithBilling;
return formAttributesWithBilling.map(attr =>
_MANDATE_ADMIN_EDITABLE.has(attr.name) ? attr : { ...attr, editable: false, readonly: true }
);
}, [formAttributesWithBilling, isPlatformAdmin]);
const toggleMandateExpand = useCallback((mandate: Mandate) => {
setExpandedMandateIds((prev) => {
const next = new Set(prev);
if (next.has(mandate.id)) {
next.delete(mandate.id);
} else {
next.add(mandate.id);
}
return next;
});
}, []);
const handleMandateUpdated = useCallback(() => {
void refetch();
setDetailRefreshKey((k) => k + 1);
}, [refetch]);
// Check if user can create
const canCreate = permissions?.create !== 'n';
const canUpdate = permissions?.update !== 'n';
const canDelete = permissions?.delete !== 'n';
// Handle edit click — load mandate + billing settings (separate persistence)
const handleEditClick = async (mandate: Mandate) => {
setEditingBillingWarning(null);
const fullMandate = await fetchMandateById(mandate.id);
if (!fullMandate) return;
try {
const settings = await fetchSettingsAdmin(request, fullMandate.id);
setEditingFormData(
mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, settings)
);
} catch {
setEditingFormData(mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, null));
setEditingBillingWarning(
t('Abrechnungseinstellungen konnten nicht geladen werden. Nur Mandantendaten sind sicher bearbeitbar.')
);
}
};
// Handle create submit — POST mandate, then billing settings
const handleCreateSubmit = async (data: Record<string, unknown>) => {
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
@ -112,27 +97,6 @@ export const AdminMandatesPage: React.FC = () => {
setShowCreateModal(false);
};
// Handle edit submit — PUT mandate + POST billing settings
const handleEditSubmit = async (data: Record<string, unknown>) => {
if (!editingFormData?.id) return;
const mandateId = String(editingFormData.id);
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
const mandateOk = await handleUpdate(mandateId, mandatePayload as Partial<Mandate>);
if (!mandateOk) {
showWarning(t('Fehler'), t('Mandant konnte nicht gespeichert werden. Fehlende Berechtigung oder Serverfehler.'));
return;
}
try {
await updateSettingsAdmin(request, mandateId, billingUpdate);
showSuccess(t('Gespeichert'), t('Mandant und Abrechnung aktualisiert.'));
} catch (e: unknown) {
console.error(e);
showWarning(t('Teilweise gespeichert'), t('Mandant gespeichert, Abrechnung konnte nicht aktualisiert werden.'));
}
setEditingFormData(null);
setEditingBillingWarning(null);
};
const handleDeleteMandate = async (mandate: Mandate) => {
if (mandate.isSystem) {
return;
@ -208,13 +172,6 @@ export const AdminMandatesPage: React.FC = () => {
</p>
</div>
<div className={styles.headerActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/user-mandates')}
>
<FaUsers /> {t('Benutzer-Zuweisungen')}
</button>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
@ -246,10 +203,13 @@ export const AdminMandatesPage: React.FC = () => {
sortable={true}
selectable={true}
actionButtons={[
{
type: 'expand' as const,
title: t('Benutzer anzeigen'),
},
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: t('Bearbeiten'),
type: 'roles' as const,
title: t('Rollen & Berechtigungen'),
}] : []),
...(canDelete ? [{
type: 'delete' as const,
@ -269,6 +229,16 @@ export const AdminMandatesPage: React.FC = () => {
: false,
}] : []}
onDelete={handleDeleteMandate}
renderExpandedRow={(mandate) => (
<MandateExpandDashboard
mandate={mandate}
canUpdate={canUpdate}
fetchMandateById={fetchMandateById}
handleUpdate={handleUpdate}
onMandateUpdated={handleMandateUpdated}
refreshKey={detailRefreshKey}
/>
)}
hookData={{
refetch,
permissions,
@ -276,6 +246,9 @@ export const AdminMandatesPage: React.FC = () => {
handleDelete,
handleInlineUpdate,
updateOptimistically,
expandedRowIds: expandedMandateIds,
isRowExpanded: isMandateExpanded,
toggleExpandedRow: toggleMandateExpand,
}}
emptyMessage={t('Keine Mandanten gefunden')}
/>
@ -320,66 +293,6 @@ export const AdminMandatesPage: React.FC = () => {
)}
<PromptDialog />
{/* Edit Modal */}
{editingFormData && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Mandant bearbeiten')}</h2>
<button
className={styles.modalClose}
onClick={() => {
setEditingFormData(null);
setEditingBillingWarning(null);
}}
>
</button>
</div>
<div className={styles.modalContent}>
{Boolean(editingFormData.isSystem) && (
<div className={styles.infoBox} style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
<span>
{t('Dies ist ein')} <strong>{t('System-Mandant')}</strong>.{' '}
{t(
'Er kann nicht gelöscht werden. Das Kurzzeichen (technischer Identifier) soll nicht geändert werden; der Volle Name kann bei Bedarf angepasst werden.'
)}
</span>
</div>
)}
{editingBillingWarning && (
<div
className={styles.infoBox}
style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}
>
{editingBillingWarning}
</div>
)}
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<FormGeneratorForm
attributes={editFormAttrs}
data={editingFormData}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => {
setEditingFormData(null);
setEditingBillingWarning(null);
}}
submitButtonText={t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
)}
</div>
</div>
</div>
)}
</div>
);
};

View file

@ -1,440 +0,0 @@
/**
* AdminUserMandatesPage
*
* Admin page for managing user-mandate memberships.
* Allows assigning users to mandates and managing their roles within mandates.
*/
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
import { useUserMandates, type MandateUser, type Mandate, type Role, type PaginationParams } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
export const AdminUserMandatesPage: React.FC = () => {
const { t } = useLanguage();
const { showError } = useToast();
const { request } = useApiRequest();
const {
users,
loading,
error,
pagination,
fetchMandateUsers,
addUserToMandate,
removeUserFromMandate,
updateUserRoles,
fetchMandates,
fetchRoles,
fetchAllUsers,
} = useUserMandates();
// Store current mandateId for refetch
const currentMandateIdRef = useRef<string>('');
// State
const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [roles, setRoles] = useState<Role[]>([]);
const [allUsers, setAllUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]);
const [showAddModal, setShowAddModal] = useState(false);
const [editingUser, setEditingUser] = useState<MandateUser | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
// Load mandates and attributes on mount
useEffect(() => {
const loadMandates = async () => {
const data = await fetchMandates();
setMandates(data);
// Auto-select first mandate if available
if (data.length > 0 && !selectedMandateId) {
setSelectedMandateId(data[0].id);
}
};
loadMandates();
fetchAttributes(request, 'UserMandateView')
.then(setBackendAttributes)
.catch(() => setBackendAttributes([]));
}, [fetchMandates, request]);
// Load users when mandate changes
useEffect(() => {
if (selectedMandateId) {
currentMandateIdRef.current = selectedMandateId;
fetchMandateUsers(selectedMandateId);
fetchRoles(selectedMandateId).then(setRoles);
}
}, [selectedMandateId, fetchMandateUsers, fetchRoles]);
// Refetch wrapper that accepts pagination params from FormGeneratorTable
const refetchWithParams = useCallback(async (paginationParams?: PaginationParams) => {
const mandateId = currentMandateIdRef.current;
if (!mandateId) return;
// If pagination params provided, pass them; otherwise just use mandateId
if (paginationParams && Object.keys(paginationParams).length > 0) {
return fetchMandateUsers(paginationParams);
}
return fetchMandateUsers(mandateId);
}, [fetchMandateUsers]);
// Load all users for the add modal
useEffect(() => {
fetchAllUsers().then(setAllUsers);
}, [fetchAllUsers]);
// Get users not yet in the mandate
const availableUsers = useMemo(() => {
const existingUserIds = new Set(users.map(u => u.userId));
return allUsers.filter(u => !existingUserIds.has(u.id));
}, [allUsers, users]);
const _rawColumns: ColumnConfig[] = useMemo(() => [
{
key: 'username',
label: t('Benutzername'),
sortable: true,
filterable: true,
searchable: true,
width: 150,
},
{
key: 'email',
label: t('E-Mail'),
sortable: true,
filterable: true,
searchable: true,
width: 200,
},
{
key: 'fullName',
label: t('Vollständiger Name'),
sortable: true,
filterable: true,
searchable: true,
width: 180,
},
{
key: 'roleLabels',
label: t('Rollen'),
sortable: false,
filterable: false,
searchable: true,
width: 200,
formatter: (value: string[]) => {
if (!value || value.length === 0) return '-';
return value.join(', ');
},
},
{
key: 'enabled',
label: t('Aktiv'),
sortable: true,
filterable: true,
searchable: false,
width: 80,
},
], [t]);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, backendAttributes),
[_rawColumns, backendAttributes],
);
// Dynamic options for forms (users and roles)
const userOptions = useMemo(() =>
availableUsers.map(u => ({
value: u.id,
label: `${u.username} ${u.email ? `(${u.email})` : ''}`
})), [availableUsers]);
const roleOptions = useMemo(() =>
roles.filter(r => !r.featureInstanceId).map(r => ({
value: r.id,
label: r.roleLabel
})), [roles]);
// Form attributes for adding a user - uses dynamic options
// Note: This is an operational form for junction table, not direct model editing
const addUserFields: AttributeDefinition[] = useMemo(() => {
// Check if backend has userId attribute to get label/description
const userIdAttr = backendAttributes.find(a => a.name === 'userId');
const roleIdsAttr = backendAttributes.find(a => a.name === 'roleIds');
return [
{
name: 'targetUserId',
label: userIdAttr?.label || t('Benutzer'),
type: 'enum' as any,
required: true,
options: userOptions,
},
{
name: 'roleIds',
label: roleIdsAttr?.label || t('Rollen'),
type: 'multiselect' as any,
required: true,
options: roleOptions,
}
];
}, [userOptions, roleOptions, backendAttributes, t]);
// Form attributes for editing user roles
const editRolesFields: AttributeDefinition[] = useMemo(() => {
const roleIdsAttr = backendAttributes.find(a => a.name === 'roleIds');
return [{
name: 'roleIds',
label: roleIdsAttr?.label || t('Rollen'),
type: 'multiselect' as any,
required: true,
options: roleOptions,
}];
}, [roleOptions, backendAttributes, t]);
// Handle add user submit
const handleAddUser = async (data: { targetUserId: string; roleIds: string[] }) => {
if (!selectedMandateId) return;
setIsSubmitting(true);
try {
const result = await addUserToMandate(selectedMandateId, data);
if (result.success) {
setShowAddModal(false);
fetchMandateUsers(selectedMandateId);
} else {
showError(t('Fehler'), result.error || t('Fehler beim Hinzufügen des Benutzers'));
}
} finally {
setIsSubmitting(false);
}
};
// Handle edit roles submit
const handleEditRoles = async (data: { roleIds: string[] }) => {
if (!selectedMandateId || !editingUser) return;
setIsSubmitting(true);
try {
const result = await updateUserRoles(selectedMandateId, editingUser.userId, data.roleIds);
if (result.success) {
setEditingUser(null);
fetchMandateUsers(selectedMandateId);
} else {
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Rollen'));
}
} finally {
setIsSubmitting(false);
}
};
// Handle remove user (confirmation handled by DeleteActionButton)
const handleRemoveUser = async (user: MandateUser) => {
if (!selectedMandateId) return;
const result = await removeUserFromMandate(selectedMandateId, user.userId);
if (!result.success) {
showError(t('Fehler'), result.error || t('Fehler beim Entfernen des Benutzers'));
}
};
// Handle edit click
const handleEditClick = (user: MandateUser) => {
setEditingUser(user);
};
if (error && !selectedMandateId) {
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Mandanten-Mitglieder')}</h1>
<p className={styles.pageSubtitle}>{t('Verwalten Sie, welche Benutzer Zugriff')}</p>
</div>
</div>
{/* Mandate Selector */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
{t('Mandant auswählen')}:
</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">{t('Mandant wählen')}</option>
{mandates.map(m => (
<option key={m.id} value={m.id}>
{mandateDisplayLabel(m)}
</option>
))}
</select>
</div>
{selectedMandateId && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => fetchMandateUsers(selectedMandateId)}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
className={styles.primaryButton}
onClick={() => setShowAddModal(true)}
disabled={availableUsers.length === 0}
>
<FaPlus /> {t('Benutzer hinzufügen')}
</button>
</div>
)}
</div>
{/* Content */}
{!selectedMandateId ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
<p className={styles.emptyDescription}>
{t('Wählen Sie einen Mandanten aus, um dessen Mitglieder zu verwalten.')}
</p>
</div>
) : (
<div className={styles.tableContainer}>
<FormGeneratorTable
data={users}
columns={columns}
apiEndpoint={selectedMandateId ? `/api/mandates/${selectedMandateId}/users` : undefined}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
actionButtons={[
{
type: 'edit' as const,
onAction: handleEditClick,
title: t('Rollen bearbeiten'),
},
{
type: 'delete' as const,
title: t('Vom Mandat entfernen'),
}
]}
onDelete={handleRemoveUser}
hookData={{
refetch: refetchWithParams,
pagination: pagination,
handleDelete: async (userMandateId: string) => {
// Find user by UserMandate ID to get userId for API call
const user = users.find(u => u.id === userMandateId);
if (user) {
const result = await removeUserFromMandate(selectedMandateId, user.userId);
return result.success;
}
return false;
},
}}
emptyMessage={t('Keine Mitglieder gefunden')}
/>
</div>
)}
{/* Add User Modal */}
{showAddModal && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Benutzer zum Mandanten hinzufügen')}</h2>
<button
className={styles.modalClose}
onClick={() => setShowAddModal(false)}
>
</button>
</div>
<div className={styles.modalContent}>
{availableUsers.length === 0 ? (
<p>{t('Alle Benutzer sind bereits diesem')}</p>
) : roleOptions.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Rollen')}</span>
</div>
) : (
<FormGeneratorForm
attributes={addUserFields}
mode="create"
onSubmit={handleAddUser}
onCancel={() => setShowAddModal(false)}
submitButtonText={isSubmitting ? t('Hinzufügen') : t('Hinzufügen')}
cancelButtonText={t('Abbrechen')}
/>
)}
</div>
</div>
</div>
)}
{/* Edit Roles Modal */}
{editingUser && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{t('Rollen bearbeiten')}: {editingUser.username}
</h2>
<button
className={styles.modalClose}
onClick={() => setEditingUser(null)}
>
</button>
</div>
<div className={styles.modalContent}>
<FormGeneratorForm
attributes={editRolesFields}
data={{ roleIds: editingUser.roleIds }}
mode="edit"
onSubmit={handleEditRoles}
onCancel={() => setEditingUser(null)}
submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
</div>
</div>
</div>
)}
</div>
);
};
export default AdminUserMandatesPage;

View file

@ -0,0 +1,366 @@
/**
* AdminUserRoleTemplatesPage
*
* Manage user role templates (system + global templates with mandateId=null).
* Mandate-specific roles are managed per tenant on Admin Mandates (roles popup).
* Feature role templates: AdminFeatureRolesPage.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
useMandateRoles,
type Role,
type RoleCreate,
type RoleUpdate,
type PaginationParams,
} from '../../hooks/useMandateRoles';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUserShield } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi';
import { fetchAttributes } from '../../api/attributesApi';
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
import styles from './Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
export const AdminUserRoleTemplatesPage: React.FC = () => {
const { t, currentLanguage } = useLanguage();
const { request } = useApiRequest();
const { showError, showWarning } = useToast();
const {
roles,
loading,
error,
pagination,
fetchUserRoleTemplates,
createRole,
updateRole,
deleteRole,
} = useMandateRoles();
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
useEffect(() => {
fetchAttributes(request, 'RoleView')
.then(setBackendAttributes)
.catch(() => setBackendAttributes([]));
}, [request]);
useEffect(() => {
fetchUserRoleTemplates();
}, [fetchUserRoleTemplates]);
const refetchWithParams = useCallback(
async (paginationParams?: PaginationParams) => {
return fetchUserRoleTemplates(paginationParams);
},
[fetchUserRoleTemplates],
);
const getDescriptionText = (desc: unknown) => {
if (!desc) return '-';
if (typeof desc === 'string') return desc;
if (typeof desc === 'object') {
const record = desc as Record<string, string>;
return (
record[currentLanguage] ||
record['xx'] ||
Object.values(record).find(v => typeof v === 'string' && v.trim()) ||
'-'
);
}
return String(desc);
};
const _rawColumns: ColumnConfig[] = useMemo(
() => [
{ key: 'roleLabel', sortable: true, filterable: true, searchable: true, width: 150 },
{
key: 'description',
sortable: false,
filterable: false,
width: 250,
formatter: (value: string) => getDescriptionText(value),
},
{ key: 'scopeType', sortable: true, filterable: true, width: 160 },
{ key: 'userCount', sortable: true, filterable: true, width: 100 },
],
[currentLanguage],
);
const columns = useMemo(
() => resolveColumnTypes(_rawColumns, backendAttributes),
[_rawColumns, backendAttributes],
);
const createFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
return backendAttributes
.filter(attr => !excludedFields.includes(attr.name))
.map(attr => ({ ...attr })) as AttributeDefinition[];
}, [backendAttributes]);
const editFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
return backendAttributes
.filter(attr => !excludedFields.includes(attr.name))
.map(attr => ({
...attr,
readonly: attr.name === 'roleLabel' ? true : attr.readonly,
})) as AttributeDefinition[];
}, [backendAttributes]);
const handleCreateTemplate = async (data: { roleLabel: string; description?: Record<string, string> }) => {
setIsSubmitting(true);
try {
const roleData: RoleCreate = {
roleLabel: data.roleLabel.toLowerCase().replace(/\s+/g, '_'),
description: data.description,
mandateId: undefined,
};
const result = await createRole(roleData);
if (result.success) {
setShowCreateModal(false);
await fetchUserRoleTemplates();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Erstellen des Rollen-Templates'));
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('Fehler beim Erstellen des Rollen-Templates');
showError(t('Fehler'), message);
} finally {
setIsSubmitting(false);
}
};
const handleEditTemplate = async (data: RoleUpdate) => {
if (!editingRole) return;
setIsSubmitting(true);
try {
const updateData: RoleUpdate = {
roleLabel: data.roleLabel,
description: data.description,
};
const result = await updateRole(editingRole.id, updateData);
if (result.success) {
setEditingRole(null);
await fetchUserRoleTemplates();
} else {
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren des Rollen-Templates'));
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : t('Fehler beim Aktualisieren des Rollen-Templates');
showError(t('Fehler'), message);
} finally {
setIsSubmitting(false);
}
};
const handleDeleteTemplate = async (roleId: string): Promise<boolean> => {
const role = roles.find(r => r.id === roleId);
if (role?.isSystemRole) {
showWarning(t('Nicht erlaubt'), t('System-Rollen können nicht gelöscht werden.'));
return false;
}
const result = await deleteRole(roleId);
if (result.success) {
await fetchUserRoleTemplates();
return true;
} else {
showError(t('Fehler'), result.error || t('Fehler beim Löschen des Rollen-Templates'));
return false;
}
};
const handleEditClick = (role: Role) => {
setEditingRole(role);
};
if (error) {
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button type="button" className={styles.secondaryButton} onClick={() => fetchUserRoleTemplates()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('User Role Templates')}</h1>
<p className={styles.pageSubtitle}>
{t('Verwalten Sie System- und globale Rollen-Vorlagen. Diese werden bei neuen Mandanten kopiert.')}
</p>
</div>
</div>
<div className={styles.filterSection}>
<div className={styles.headerActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => fetchUserRoleTemplates()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
<button
type="button"
className={styles.primaryButton}
onClick={() => setShowCreateModal(true)}
>
<FaPlus /> {t('Neues Rollen-Template')}
</button>
</div>
</div>
<div className={styles.infoBox}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
<strong>{t('System-Templates')}</strong>{' '}
{t(
'(admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert und können hier nicht gelöscht werden.',
)}{' '}
<strong>{t('Globale Templates')}</strong>{' '}
{t('gelten für alle neuen Mandanten. Mandanten-spezifische Rollen verwalten Sie unter Mandanten.')}
</span>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable
data={roles}
columns={columns}
apiEndpoint="/api/rbac/roles"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
actionButtons={[
{
type: 'edit' as const,
onAction: handleEditClick,
title: t('Rollen-Template bearbeiten'),
disabled: (row: Role) =>
row.isSystemRole
? { disabled: true, message: t('System-Rollen können nicht bearbeitet werden') }
: false,
},
{
type: 'delete' as const,
title: t('Rollen-Template löschen'),
disabled: (row: Role) =>
row.isSystemRole
? { disabled: true, message: t('System-Rollen können nicht gelöscht werden') }
: false,
},
]}
onDelete={handleDeleteTemplate}
hookData={{
refetch: refetchWithParams,
pagination: pagination,
handleDelete: handleDeleteTemplate,
}}
emptyMessage={t('Keine Rollen-Templates gefunden')}
/>
</div>
{showCreateModal && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Neues Rollen-Template erstellen')}</h2>
<button type="button" className={styles.modalClose} onClick={() => setShowCreateModal(false)}>
</button>
</div>
<div className={styles.modalContent}>
{createFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<FormGeneratorForm
attributes={createFields}
mode="create"
onSubmit={handleCreateTemplate}
onCancel={() => setShowCreateModal(false)}
submitButtonText={isSubmitting ? t('Erstelle…') : t('Rollen-Template erstellen')}
cancelButtonText={t('Abbrechen')}
/>
)}
</div>
</div>
</div>
)}
{editingRole && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
{t('Rollen-Template bearbeiten')}: {editingRole.roleLabel}
</h2>
<button type="button" className={styles.modalClose} onClick={() => setEditingRole(null)}>
</button>
</div>
<div className={styles.modalContent}>
{editFields.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Lade Formular')}</span>
</div>
) : (
<>
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaUserShield style={{ marginRight: 8 }} />
<span>
{t('Geltungsbereich')}:{' '}
<strong>
{editingRole.isSystemRole ? t('System-Template') : t('Template (global)')}
</strong>{' '}
({t('kann nicht geändert werden')})
</span>
</div>
<FormGeneratorForm
attributes={editFields}
data={editingRole}
mode="edit"
onSubmit={handleEditTemplate}
onCancel={() => setEditingRole(null)}
submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
</>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default AdminUserRoleTemplatesPage;

View file

@ -7,13 +7,11 @@
export { AccessManagementHub } from './AccessManagementHub';
export { AdminMandatesPage } from './AdminMandatesPage';
export { AdminUsersPage } from './AdminUsersPage';
export { AdminUserMandatesPage } from './AdminUserMandatesPage';
export { AdminFeatureAccessPage } from './AdminFeatureAccessPage';
export { AdminInvitationsPage } from './AdminInvitationsPage';
export { AdminMandateRolesPage } from './AdminMandateRolesPage';
export { AdminUserRoleTemplatesPage } from './AdminUserRoleTemplatesPage';
export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
export { AdminLogsPage } from './AdminLogsPage';
export { AdminLanguagesPage } from './AdminLanguagesPage';

View file

@ -64,6 +64,42 @@ function _parseNotifyEmailsInput(val: unknown): string[] {
.filter(Boolean);
}
/** Formatted multi-line invoice address for read-only display. */
export function formatMandateInvoiceAddress(data: Record<string, unknown>): string | null {
const lines: string[] = [];
const company = data.invoiceCompanyName;
const contact = data.invoiceContactName;
const line1 = data.invoiceLine1;
const line2 = data.invoiceLine2;
const postal = data.invoicePostalCode;
const city = data.invoiceCity;
const state = data.invoiceState;
const country = data.invoiceCountry;
const vat = data.invoiceVatNumber;
const email = data.invoiceEmail;
if (typeof company === 'string' && company.trim()) lines.push(company.trim());
if (typeof contact === 'string' && contact.trim()) lines.push(`z. H. ${contact.trim()}`);
if (typeof line1 === 'string' && line1.trim()) lines.push(line1.trim());
if (typeof line2 === 'string' && line2.trim()) lines.push(line2.trim());
const cityLine = [postal, city].filter((v) => typeof v === 'string' && v.trim()).join(' ');
if (cityLine.trim()) {
const withState =
typeof state === 'string' && state.trim() ? `${cityLine.trim()} (${state.trim()})` : cityLine.trim();
lines.push(withState);
}
if (typeof country === 'string' && country.trim()) lines.push(country.trim());
if (typeof vat === 'string' && vat.trim()) lines.push(`UID: ${vat.trim()}`);
if (typeof email === 'string' && email.trim()) lines.push(email.trim());
return lines.length > 0 ? lines.join('\n') : null;
}
export function isMandateBillingField(fieldName: string): fieldName is MandateBillingFieldName {
return (mandateBillingFieldNames as readonly string[]).includes(fieldName);
}
export function mergeBillingIntoMandateFormData(
mandate: Record<string, unknown>,
settings: BillingSettings | null
@ -84,8 +120,8 @@ export function mergeBillingIntoMandateFormData(
};
}
/** Mandate fields that the AdminMandates form is allowed to update. */
const _MANDATE_INVOICE_FIELDS = [
/** Mandate invoice address fields (read-only display + form update). */
export const MANDATE_INVOICE_FIELD_NAMES = [
'invoiceCompanyName',
'invoiceContactName',
'invoiceEmail',
@ -98,6 +134,8 @@ const _MANDATE_INVOICE_FIELDS = [
'invoiceVatNumber',
] as const;
const _MANDATE_INVOICE_FIELDS = MANDATE_INVOICE_FIELD_NAMES;
/**
* Split form submit payload into mandate PUT body and billing POST body.
*
@ -114,6 +152,7 @@ export function splitMandateAndBillingFromForm(
if ('name' in formData) mandatePayload.name = formData.name;
if ('label' in formData) mandatePayload.label = formData.label;
if ('enabled' in formData) mandatePayload.enabled = formData.enabled;
if ('mfaRequired' in formData) mandatePayload.mfaRequired = formData.mfaRequired;
for (const fieldName of _MANDATE_INVOICE_FIELDS) {
if (!(fieldName in formData)) continue;
const raw = formData[fieldName];