moved tenant member view and functionality to tenant page

This commit is contained in:
Ida 2026-06-04 14:14:20 +02:00
parent 849efa6ed7
commit eb0a58aaa7
14 changed files with 1166 additions and 441 deletions

View file

@ -0,0 +1,39 @@
.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.15s ease;
}
.chevronIconExpanded {
transform: rotate(90deg);
}

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

@ -6,6 +6,7 @@ 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';
@ -18,4 +19,5 @@ 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

@ -578,6 +578,34 @@
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: 12px 16px 16px;
vertical-align: top;
border-bottom: 2px solid var(--color-border, #e2e8f0);
box-sizing: border-box;
}
.expandedDetailInner {
width: 100%;
max-width: 100%;
min-width: 0;
overflow-x: auto;
overflow-y: visible;
}
/* 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

@ -65,6 +65,7 @@ import {
ViewActionButton,
CopyActionButton,
RolesActionButton,
ExpandActionButton,
CustomActionButton
} from '../ActionButtons';
import { formatUnixTimestamp } from '../../../utils/time';
@ -265,7 +266,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' | 'roles' | '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 };
@ -356,6 +357,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;
@ -713,6 +716,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
@ -2645,79 +2649,131 @@ 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} />;
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)}
<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>
{isExpanded && renderExpandedRow && (
<tr className={styles.expandedDetailRow}>
<td
colSpan={detailColSpan}
className={styles.expandedDetailCell}
onClick={(e) => e.stopPropagation()}
>
<div className={styles.expandedDetailInner}>
{renderExpandedRow(row)}
</div>
</td>
</tr>
)}
</React.Fragment>
);
};

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,148 @@
.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;
}
.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,385 @@
/**
* MandateUsersPanel manage users within a single mandate (members, roles, add/remove).
* Shared by AdminMandatesPage (expanded row) and AdminUserMandatesPage.
*/
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;
}
export const MandateUsersPanel: React.FC<MandateUsersPanelProps> = ({
mandateId,
embedded = 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 : ''}`}>
<div className={`${panelStyles.card} ${embedded ? panelStyles.cardEmbedded : ''}`}>
<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

@ -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

@ -4,7 +4,7 @@
* Admin page for managing Mandates (tenants) using FormGeneratorTable.
*/
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates';
import { useApiRequest } from '../../hooks/useApi';
@ -17,6 +17,7 @@ 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 { MandateUsersPanel } from '../../components/admin/MandateUsersPanel';
import { FaPlus, FaSync, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
import { getUserDataCache } from '../../utils/userCache';
import styles from './Admin.module.css';
@ -59,6 +60,24 @@ export const AdminMandatesPage: React.FC = () => {
/** 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 isMandateExpanded = useCallback(
(mandate: Mandate) => expandedMandateIds.has(mandate.id),
[expandedMandateIds],
);
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 isPlatformAdmin = getUserDataCache()?.isPlatformAdmin === true;
@ -246,6 +265,10 @@ 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,
@ -273,6 +296,9 @@ export const AdminMandatesPage: React.FC = () => {
: false,
}] : []}
onDelete={handleDeleteMandate}
renderExpandedRow={(mandate) => (
<MandateUsersPanel mandateId={mandate.id} embedded />
)}
hookData={{
refetch,
permissions,
@ -280,6 +306,9 @@ export const AdminMandatesPage: React.FC = () => {
handleDelete,
handleInlineUpdate,
updateOptimistically,
expandedRowIds: expandedMandateIds,
isRowExpanded: isMandateExpanded,
toggleExpandedRow: toggleMandateExpand,
}}
emptyMessage={t('Keine Mandanten gefunden')}
/>

View file

@ -1,254 +1,37 @@
/**
* 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 React, { useState, useEffect } from 'react';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { FaSync, FaBuilding } from 'react-icons/fa';
import { MandateUsersPanel } from '../../components/admin/MandateUsersPanel';
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 { 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>('');
const { error, fetchMandates } = useUserMandates();
// 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);
if (data.length > 0) {
setSelectedMandateId((prev) => prev || 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);
};
}, [fetchMandates]);
if (error && !selectedMandateId) {
return (
@ -258,7 +41,7 @@ export const AdminUserMandatesPage: React.FC = () => {
<p className={styles.errorMessage}>
{t('Fehler')}: {error}
</p>
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
<button type="button" className={styles.secondaryButton} onClick={() => fetchMandates()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
@ -275,7 +58,6 @@ export const AdminUserMandatesPage: React.FC = () => {
</div>
</div>
{/* Mandate Selector */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
@ -288,35 +70,15 @@ export const AdminUserMandatesPage: React.FC = () => {
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">{t('Mandant wählen')}</option>
{mandates.map(m => (
{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} />
@ -327,110 +89,7 @@ export const AdminUserMandatesPage: React.FC = () => {
</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>
<MandateUsersPanel mandateId={selectedMandateId} />
</div>
)}
</div>