moved tenant member view and functionality to tenant page
This commit is contained in:
parent
849efa6ed7
commit
eb0a58aaa7
14 changed files with 1166 additions and 441 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { ExpandActionButton } from './ExpandActionButton';
|
||||
export type { ExpandActionButtonProps } from './ExpandActionButton';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
102
src/components/admin/InlineRoleMultiselect.module.css
Normal file
102
src/components/admin/InlineRoleMultiselect.module.css
Normal 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;
|
||||
}
|
||||
191
src/components/admin/InlineRoleMultiselect.tsx
Normal file
191
src/components/admin/InlineRoleMultiselect.tsx
Normal 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;
|
||||
148
src/components/admin/MandateUsersPanel.module.css
Normal file
148
src/components/admin/MandateUsersPanel.module.css
Normal 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;
|
||||
}
|
||||
385
src/components/admin/MandateUsersPanel.tsx
Normal file
385
src/components/admin/MandateUsersPanel.tsx
Normal 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;
|
||||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue