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 { RemoveActionButton } from './RemoveActionButton';
|
||||||
export { DownloadActionButton } from './DownloadActionButton';
|
export { DownloadActionButton } from './DownloadActionButton';
|
||||||
export { RolesActionButton } from './RolesActionButton';
|
export { RolesActionButton } from './RolesActionButton';
|
||||||
|
export { ExpandActionButton } from './ExpandActionButton';
|
||||||
|
|
||||||
// Generic Custom Action Button (for entity-specific actions)
|
// Generic Custom Action Button (for entity-specific actions)
|
||||||
export { CustomActionButton } from './CustomActionButton';
|
export { CustomActionButton } from './CustomActionButton';
|
||||||
|
|
@ -18,4 +19,5 @@ export type { CopyActionButtonProps } from './CopyActionButton';
|
||||||
export type { RemoveActionButtonProps } from './RemoveActionButton';
|
export type { RemoveActionButtonProps } from './RemoveActionButton';
|
||||||
export type { DownloadActionButtonProps } from './DownloadActionButton';
|
export type { DownloadActionButtonProps } from './DownloadActionButton';
|
||||||
export type { RolesActionButtonProps } from './RolesActionButton';
|
export type { RolesActionButtonProps } from './RolesActionButton';
|
||||||
|
export type { ExpandActionButtonProps } from './ExpandActionButton';
|
||||||
export type { CustomActionButtonProps } from './CustomActionButton';
|
export type { CustomActionButtonProps } from './CustomActionButton';
|
||||||
|
|
|
||||||
|
|
@ -578,6 +578,34 @@
|
||||||
cursor: pointer;
|
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 */
|
/* Items that live inside a group — subtle tint + left connector */
|
||||||
.tr.groupedItem {
|
.tr.groupedItem {
|
||||||
border-left: 3px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 35%, transparent);
|
border-left: 3px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 35%, transparent);
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,7 @@ import {
|
||||||
ViewActionButton,
|
ViewActionButton,
|
||||||
CopyActionButton,
|
CopyActionButton,
|
||||||
RolesActionButton,
|
RolesActionButton,
|
||||||
|
ExpandActionButton,
|
||||||
CustomActionButton
|
CustomActionButton
|
||||||
} from '../ActionButtons';
|
} from '../ActionButtons';
|
||||||
import { formatUnixTimestamp } from '../../../utils/time';
|
import { formatUnixTimestamp } from '../../../utils/time';
|
||||||
|
|
@ -265,7 +266,7 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
idField?: string; // Field name for unique row identifier (default: 'id')
|
idField?: string; // Field name for unique row identifier (default: 'id')
|
||||||
// Standard action buttons (edit, delete, view, copy, connect, play)
|
// Standard action buttons (edit, delete, view, copy, connect, play)
|
||||||
actionButtons?: {
|
actionButtons?: {
|
||||||
type: 'edit' | 'delete' | 'view' | 'copy' | 'roles' | 'connect' | 'play';
|
type: 'expand' | 'edit' | 'delete' | 'view' | 'copy' | 'roles' | 'connect' | 'play';
|
||||||
onAction?: (row: T) => Promise<void> | void;
|
onAction?: (row: T) => Promise<void> | void;
|
||||||
visible?: (row: T, hookData?: any) => boolean;
|
visible?: (row: T, hookData?: any) => boolean;
|
||||||
disabled?: (row: T, hookData?: any) => boolean | { disabled: boolean; message?: string };
|
disabled?: (row: T, hookData?: any) => boolean | { disabled: boolean; message?: string };
|
||||||
|
|
@ -356,6 +357,8 @@ export interface FormGeneratorTableProps<T = any> {
|
||||||
csvExportContextFilters?: Record<string, unknown>;
|
csvExportContextFilters?: Record<string, unknown>;
|
||||||
/** Optional download filename token (sanitized), e.g. group key for nested exports. */
|
/** Optional download filename token (sanitized), e.g. group key for nested exports. */
|
||||||
csvExportFilenameSuffix?: string;
|
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;
|
const _FILTER_PAGE_SIZE = 100;
|
||||||
|
|
@ -713,6 +716,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
csvExportQueryParams,
|
csvExportQueryParams,
|
||||||
csvExportContextFilters,
|
csvExportContextFilters,
|
||||||
csvExportFilenameSuffix,
|
csvExportFilenameSuffix,
|
||||||
|
renderExpandedRow,
|
||||||
}: FormGeneratorTableProps<T>) {
|
}: FormGeneratorTableProps<T>) {
|
||||||
const { t, currentLanguage: contextLanguage } = useLanguage();
|
const { t, currentLanguage: contextLanguage } = useLanguage();
|
||||||
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
|
// 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 ||
|
batchActions.length > 0 ||
|
||||||
(selectable && selectedIds.size > 0);
|
(selectable && selectedIds.size > 0);
|
||||||
|
|
||||||
|
const expandActionConfig = useMemo(
|
||||||
|
() => actionButtons.find((ab) => ab.type === 'expand'),
|
||||||
|
[actionButtons],
|
||||||
|
);
|
||||||
|
|
||||||
const _renderDataRow = (row: T, index: number) => {
|
const _renderDataRow = (row: T, index: number) => {
|
||||||
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
|
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
|
||||||
const rowId = _getRowId(row);
|
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 (
|
return (
|
||||||
<tr
|
<React.Fragment key={rowId || index}>
|
||||||
key={rowId || index}
|
<tr
|
||||||
className={`${styles.tr} ${selectedIds.has(rowId) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
className={`${styles.tr} ${selectedIds.has(rowId) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''} ${isExpanded ? styles.expandedParentRow : ''}`}
|
||||||
onClick={() => onRowClick?.(row, index)}
|
onClick={() => onRowClick?.(row, index)}
|
||||||
draggable={rowDraggable}
|
draggable={rowDraggable}
|
||||||
onDragStart={(e) => {
|
onDragStart={(e) => {
|
||||||
if (rowDraggable && onRowDragStart) onRowDragStart(e, row);
|
if (rowDraggable && onRowDragStart) onRowDragStart(e, row);
|
||||||
}}
|
}}
|
||||||
onDragEnd={() => {}}
|
onDragEnd={() => {}}
|
||||||
{...Object.fromEntries(Object.entries(dataAttributes).map(([k, v]) => [`data-${k}`, v]))}
|
{...Object.fromEntries(Object.entries(dataAttributes).map(([k, v]) => [`data-${k}`, v]))}
|
||||||
>
|
>
|
||||||
{selectable && (
|
{selectable && (
|
||||||
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
checked={selectedIds.has(rowId)}
|
checked={selectedIds.has(rowId)}
|
||||||
onChange={() => handleRowSelect(row)}
|
onChange={() => handleRowSelect(row)}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
disabled={isRowSelectable && !isRowSelectable(row)}
|
disabled={isRowSelectable && !isRowSelectable(row)}
|
||||||
title={isRowSelectable && !isRowSelectable(row) ? t('Dieses Element kann nicht ausgewählt werden') : t('Dieses Element auswählen')}
|
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' }}
|
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)}
|
|
||||||
</td>
|
</td>
|
||||||
);
|
)}
|
||||||
})}
|
{hasActionColumn && (
|
||||||
</tr>
|
<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
|
// Re-export types
|
||||||
export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams };
|
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 {
|
export interface AttributeDefinition {
|
||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
|
@ -157,17 +168,21 @@ export function useAdminMandates() {
|
||||||
|
|
||||||
// Generate columns from attributes (types merged via resolveColumnTypes)
|
// Generate columns from attributes (types merged via resolveColumnTypes)
|
||||||
const columns: ColumnConfig[] = useMemo(() => {
|
const columns: ColumnConfig[] = useMemo(() => {
|
||||||
const raw = attributes.map(attr => ({
|
const raw = MANDATE_LIST_COLUMN_KEYS.map((key) => {
|
||||||
key: attr.name,
|
const attr = attributes.find((a) => a.name === key);
|
||||||
label: attr.label || attr.name,
|
if (!attr) return null;
|
||||||
sortable: attr.sortable !== false,
|
return {
|
||||||
filterable: attr.filterable !== false,
|
key: attr.name,
|
||||||
searchable: attr.searchable !== false,
|
label: attr.label || attr.name,
|
||||||
width: attr.width || 150,
|
sortable: attr.sortable !== false,
|
||||||
minWidth: attr.minWidth || 100,
|
filterable: attr.filterable !== false,
|
||||||
maxWidth: attr.maxWidth || 400,
|
searchable: attr.searchable !== false,
|
||||||
displayField: (attr as any).displayField,
|
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);
|
return resolveColumnTypes(raw, attributes);
|
||||||
}, [attributes]);
|
}, [attributes]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -235,13 +235,14 @@ export function useUserMandates() {
|
||||||
* roles should be offered for user assignment - NOT global templates.
|
* roles should be offered for user assignment - NOT global templates.
|
||||||
*/
|
*/
|
||||||
const fetchRoles = useCallback(async (mandateId?: string): Promise<Role[]> => {
|
const fetchRoles = useCallback(async (mandateId?: string): Promise<Role[]> => {
|
||||||
|
if (!mandateId) return [];
|
||||||
try {
|
try {
|
||||||
const params: Record<string, string> = {};
|
const headers: Record<string, string> = { 'X-Mandate-Id': mandateId };
|
||||||
if (mandateId) {
|
const params: Record<string, string> = {
|
||||||
params.mandateId = mandateId;
|
mandateId,
|
||||||
params.scopeFilter = 'mandate';
|
includeTemplates: 'false',
|
||||||
}
|
};
|
||||||
const response = await api.get('/api/rbac/roles', { params });
|
const response = await api.get('/api/rbac/roles', { headers, params });
|
||||||
let roles: Role[] = [];
|
let roles: Role[] = [];
|
||||||
if (response.data?.items && Array.isArray(response.data.items)) {
|
if (response.data?.items && Array.isArray(response.data.items)) {
|
||||||
roles = response.data.items;
|
roles = response.data.items;
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
* Admin page for managing Mandates (tenants) using FormGeneratorTable.
|
* 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 { useNavigate } from 'react-router-dom';
|
||||||
import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates';
|
import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates';
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
|
|
@ -17,6 +17,7 @@ import { useToast } from '../../contexts/ToastContext';
|
||||||
import { usePrompt } from '../../hooks/usePrompt';
|
import { usePrompt } from '../../hooks/usePrompt';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
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 { FaPlus, FaSync, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
|
||||||
import { getUserDataCache } from '../../utils/userCache';
|
import { getUserDataCache } from '../../utils/userCache';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
@ -59,6 +60,24 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
/** Mandate row merged with billing fields for FormGenerator */
|
/** Mandate row merged with billing fields for FormGenerator */
|
||||||
const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null);
|
const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null);
|
||||||
const [editingBillingWarning, setEditingBillingWarning] = useState<string | 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;
|
const isPlatformAdmin = getUserDataCache()?.isPlatformAdmin === true;
|
||||||
|
|
||||||
|
|
@ -246,6 +265,10 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
sortable={true}
|
sortable={true}
|
||||||
selectable={true}
|
selectable={true}
|
||||||
actionButtons={[
|
actionButtons={[
|
||||||
|
{
|
||||||
|
type: 'expand' as const,
|
||||||
|
title: t('Benutzer anzeigen'),
|
||||||
|
},
|
||||||
...(canUpdate ? [{
|
...(canUpdate ? [{
|
||||||
type: 'edit' as const,
|
type: 'edit' as const,
|
||||||
onAction: handleEditClick,
|
onAction: handleEditClick,
|
||||||
|
|
@ -273,6 +296,9 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
: false,
|
: false,
|
||||||
}] : []}
|
}] : []}
|
||||||
onDelete={handleDeleteMandate}
|
onDelete={handleDeleteMandate}
|
||||||
|
renderExpandedRow={(mandate) => (
|
||||||
|
<MandateUsersPanel mandateId={mandate.id} embedded />
|
||||||
|
)}
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch,
|
refetch,
|
||||||
permissions,
|
permissions,
|
||||||
|
|
@ -280,6 +306,9 @@ export const AdminMandatesPage: React.FC = () => {
|
||||||
handleDelete,
|
handleDelete,
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
updateOptimistically,
|
updateOptimistically,
|
||||||
|
expandedRowIds: expandedMandateIds,
|
||||||
|
isRowExpanded: isMandateExpanded,
|
||||||
|
toggleExpandedRow: toggleMandateExpand,
|
||||||
}}
|
}}
|
||||||
emptyMessage={t('Keine Mandanten gefunden')}
|
emptyMessage={t('Keine Mandanten gefunden')}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -5,250 +5,33 @@
|
||||||
* Allows assigning users to mandates and managing their roles within mandates.
|
* Allows assigning users to mandates and managing their roles within mandates.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useUserMandates, type MandateUser, type Mandate, type Role, type PaginationParams } from '../../hooks/useUserMandates';
|
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FaSync, FaBuilding } from 'react-icons/fa';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { MandateUsersPanel } from '../../components/admin/MandateUsersPanel';
|
||||||
import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa';
|
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
|
||||||
import { fetchAttributes } from '../../api/attributesApi';
|
|
||||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
|
||||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
||||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||||
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||||
|
|
||||||
export const AdminUserMandatesPage: React.FC = () => {
|
export const AdminUserMandatesPage: React.FC = () => {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
const { showError } = useToast();
|
const { error, fetchMandates } = useUserMandates();
|
||||||
const { request } = useApiRequest();
|
|
||||||
const {
|
|
||||||
users,
|
|
||||||
loading,
|
|
||||||
error,
|
|
||||||
pagination,
|
|
||||||
fetchMandateUsers,
|
|
||||||
addUserToMandate,
|
|
||||||
removeUserFromMandate,
|
|
||||||
updateUserRoles,
|
|
||||||
fetchMandates,
|
|
||||||
fetchRoles,
|
|
||||||
fetchAllUsers,
|
|
||||||
} = useUserMandates();
|
|
||||||
|
|
||||||
// Store current mandateId for refetch
|
|
||||||
const currentMandateIdRef = useRef<string>('');
|
|
||||||
|
|
||||||
// State
|
|
||||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
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(() => {
|
useEffect(() => {
|
||||||
const loadMandates = async () => {
|
const loadMandates = async () => {
|
||||||
const data = await fetchMandates();
|
const data = await fetchMandates();
|
||||||
setMandates(data);
|
setMandates(data);
|
||||||
// Auto-select first mandate if available
|
if (data.length > 0) {
|
||||||
if (data.length > 0 && !selectedMandateId) {
|
setSelectedMandateId((prev) => prev || data[0].id);
|
||||||
setSelectedMandateId(data[0].id);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadMandates();
|
loadMandates();
|
||||||
fetchAttributes(request, 'UserMandateView')
|
}, [fetchMandates]);
|
||||||
.then(setBackendAttributes)
|
|
||||||
.catch(() => setBackendAttributes([]));
|
|
||||||
}, [fetchMandates, request]);
|
|
||||||
|
|
||||||
// Load users when mandate changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (selectedMandateId) {
|
|
||||||
currentMandateIdRef.current = selectedMandateId;
|
|
||||||
fetchMandateUsers(selectedMandateId);
|
|
||||||
fetchRoles(selectedMandateId).then(setRoles);
|
|
||||||
}
|
|
||||||
}, [selectedMandateId, fetchMandateUsers, fetchRoles]);
|
|
||||||
|
|
||||||
// Refetch wrapper that accepts pagination params from FormGeneratorTable
|
|
||||||
const refetchWithParams = useCallback(async (paginationParams?: PaginationParams) => {
|
|
||||||
const mandateId = currentMandateIdRef.current;
|
|
||||||
if (!mandateId) return;
|
|
||||||
// If pagination params provided, pass them; otherwise just use mandateId
|
|
||||||
if (paginationParams && Object.keys(paginationParams).length > 0) {
|
|
||||||
return fetchMandateUsers(paginationParams);
|
|
||||||
}
|
|
||||||
return fetchMandateUsers(mandateId);
|
|
||||||
}, [fetchMandateUsers]);
|
|
||||||
|
|
||||||
// Load all users for the add modal
|
|
||||||
useEffect(() => {
|
|
||||||
fetchAllUsers().then(setAllUsers);
|
|
||||||
}, [fetchAllUsers]);
|
|
||||||
|
|
||||||
// Get users not yet in the mandate
|
|
||||||
const availableUsers = useMemo(() => {
|
|
||||||
const existingUserIds = new Set(users.map(u => u.userId));
|
|
||||||
return allUsers.filter(u => !existingUserIds.has(u.id));
|
|
||||||
}, [allUsers, users]);
|
|
||||||
|
|
||||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
|
||||||
{
|
|
||||||
key: 'username',
|
|
||||||
label: t('Benutzername'),
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
searchable: true,
|
|
||||||
width: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'email',
|
|
||||||
label: t('E-Mail'),
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
searchable: true,
|
|
||||||
width: 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'fullName',
|
|
||||||
label: t('Vollständiger Name'),
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
searchable: true,
|
|
||||||
width: 180,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'roleLabels',
|
|
||||||
label: t('Rollen'),
|
|
||||||
sortable: false,
|
|
||||||
filterable: false,
|
|
||||||
searchable: true,
|
|
||||||
width: 200,
|
|
||||||
formatter: (value: string[]) => {
|
|
||||||
if (!value || value.length === 0) return '-';
|
|
||||||
return value.join(', ');
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'enabled',
|
|
||||||
label: t('Aktiv'),
|
|
||||||
sortable: true,
|
|
||||||
filterable: true,
|
|
||||||
searchable: false,
|
|
||||||
width: 80,
|
|
||||||
},
|
|
||||||
], [t]);
|
|
||||||
|
|
||||||
const columns = useMemo(
|
|
||||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
|
||||||
[_rawColumns, backendAttributes],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Dynamic options for forms (users and roles)
|
|
||||||
const userOptions = useMemo(() =>
|
|
||||||
availableUsers.map(u => ({
|
|
||||||
value: u.id,
|
|
||||||
label: `${u.username} ${u.email ? `(${u.email})` : ''}`
|
|
||||||
})), [availableUsers]);
|
|
||||||
|
|
||||||
const roleOptions = useMemo(() =>
|
|
||||||
roles.filter(r => !r.featureInstanceId).map(r => ({
|
|
||||||
value: r.id,
|
|
||||||
label: r.roleLabel
|
|
||||||
})), [roles]);
|
|
||||||
|
|
||||||
// Form attributes for adding a user - uses dynamic options
|
|
||||||
// Note: This is an operational form for junction table, not direct model editing
|
|
||||||
const addUserFields: AttributeDefinition[] = useMemo(() => {
|
|
||||||
// Check if backend has userId attribute to get label/description
|
|
||||||
const userIdAttr = backendAttributes.find(a => a.name === 'userId');
|
|
||||||
const roleIdsAttr = backendAttributes.find(a => a.name === 'roleIds');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: 'targetUserId',
|
|
||||||
label: userIdAttr?.label || t('Benutzer'),
|
|
||||||
type: 'enum' as any,
|
|
||||||
required: true,
|
|
||||||
options: userOptions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'roleIds',
|
|
||||||
label: roleIdsAttr?.label || t('Rollen'),
|
|
||||||
type: 'multiselect' as any,
|
|
||||||
required: true,
|
|
||||||
options: roleOptions,
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}, [userOptions, roleOptions, backendAttributes, t]);
|
|
||||||
|
|
||||||
// Form attributes for editing user roles
|
|
||||||
const editRolesFields: AttributeDefinition[] = useMemo(() => {
|
|
||||||
const roleIdsAttr = backendAttributes.find(a => a.name === 'roleIds');
|
|
||||||
|
|
||||||
return [{
|
|
||||||
name: 'roleIds',
|
|
||||||
label: roleIdsAttr?.label || t('Rollen'),
|
|
||||||
type: 'multiselect' as any,
|
|
||||||
required: true,
|
|
||||||
options: roleOptions,
|
|
||||||
}];
|
|
||||||
}, [roleOptions, backendAttributes, t]);
|
|
||||||
|
|
||||||
// Handle add user submit
|
|
||||||
const handleAddUser = async (data: { targetUserId: string; roleIds: string[] }) => {
|
|
||||||
if (!selectedMandateId) return;
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const result = await addUserToMandate(selectedMandateId, data);
|
|
||||||
if (result.success) {
|
|
||||||
setShowAddModal(false);
|
|
||||||
fetchMandateUsers(selectedMandateId);
|
|
||||||
} else {
|
|
||||||
showError(t('Fehler'), result.error || t('Fehler beim Hinzufügen des Benutzers'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle edit roles submit
|
|
||||||
const handleEditRoles = async (data: { roleIds: string[] }) => {
|
|
||||||
if (!selectedMandateId || !editingUser) return;
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const result = await updateUserRoles(selectedMandateId, editingUser.userId, data.roleIds);
|
|
||||||
if (result.success) {
|
|
||||||
setEditingUser(null);
|
|
||||||
fetchMandateUsers(selectedMandateId);
|
|
||||||
} else {
|
|
||||||
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Rollen'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle remove user (confirmation handled by DeleteActionButton)
|
|
||||||
const handleRemoveUser = async (user: MandateUser) => {
|
|
||||||
if (!selectedMandateId) return;
|
|
||||||
const result = await removeUserFromMandate(selectedMandateId, user.userId);
|
|
||||||
if (!result.success) {
|
|
||||||
showError(t('Fehler'), result.error || t('Fehler beim Entfernen des Benutzers'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle edit click
|
|
||||||
const handleEditClick = (user: MandateUser) => {
|
|
||||||
setEditingUser(user);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (error && !selectedMandateId) {
|
if (error && !selectedMandateId) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -258,7 +41,7 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
<p className={styles.errorMessage}>
|
<p className={styles.errorMessage}>
|
||||||
{t('Fehler')}: {error}
|
{t('Fehler')}: {error}
|
||||||
</p>
|
</p>
|
||||||
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
|
<button type="button" className={styles.secondaryButton} onClick={() => fetchMandates()}>
|
||||||
<FaSync /> {t('Erneut versuchen')}
|
<FaSync /> {t('Erneut versuchen')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -275,7 +58,6 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mandate Selector */}
|
|
||||||
<div className={styles.filterSection}>
|
<div className={styles.filterSection}>
|
||||||
<div className={styles.filterGroup}>
|
<div className={styles.filterGroup}>
|
||||||
<label className={styles.filterLabel}>
|
<label className={styles.filterLabel}>
|
||||||
|
|
@ -288,35 +70,15 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
onChange={(e) => setSelectedMandateId(e.target.value)}
|
onChange={(e) => setSelectedMandateId(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">{t('Mandant wählen')}</option>
|
<option value="">{t('Mandant wählen')}</option>
|
||||||
{mandates.map(m => (
|
{mandates.map((m) => (
|
||||||
<option key={m.id} value={m.id}>
|
<option key={m.id} value={m.id}>
|
||||||
{mandateDisplayLabel(m)}
|
{mandateDisplayLabel(m)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
{!selectedMandateId ? (
|
{!selectedMandateId ? (
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<FaBuilding className={styles.emptyIcon} />
|
<FaBuilding className={styles.emptyIcon} />
|
||||||
|
|
@ -327,110 +89,7 @@ export const AdminUserMandatesPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.tableContainer}>
|
<div className={styles.tableContainer}>
|
||||||
<FormGeneratorTable
|
<MandateUsersPanel mandateId={selectedMandateId} />
|
||||||
data={users}
|
|
||||||
columns={columns}
|
|
||||||
apiEndpoint={selectedMandateId ? `/api/mandates/${selectedMandateId}/users` : undefined}
|
|
||||||
loading={loading}
|
|
||||||
pagination={true}
|
|
||||||
pageSize={25}
|
|
||||||
searchable={true}
|
|
||||||
filterable={true}
|
|
||||||
sortable={true}
|
|
||||||
selectable={true}
|
|
||||||
actionButtons={[
|
|
||||||
{
|
|
||||||
type: 'edit' as const,
|
|
||||||
onAction: handleEditClick,
|
|
||||||
title: t('Rollen bearbeiten'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'delete' as const,
|
|
||||||
title: t('Vom Mandat entfernen'),
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
onDelete={handleRemoveUser}
|
|
||||||
hookData={{
|
|
||||||
refetch: refetchWithParams,
|
|
||||||
pagination: pagination,
|
|
||||||
handleDelete: async (userMandateId: string) => {
|
|
||||||
// Find user by UserMandate ID to get userId for API call
|
|
||||||
const user = users.find(u => u.id === userMandateId);
|
|
||||||
if (user) {
|
|
||||||
const result = await removeUserFromMandate(selectedMandateId, user.userId);
|
|
||||||
return result.success;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
emptyMessage={t('Keine Mitglieder gefunden')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add User Modal */}
|
|
||||||
{showAddModal && (
|
|
||||||
<div className={styles.modalOverlay}>
|
|
||||||
<div className={styles.modal}>
|
|
||||||
<div className={styles.modalHeader}>
|
|
||||||
<h2 className={styles.modalTitle}>{t('Benutzer zum Mandanten hinzufügen')}</h2>
|
|
||||||
<button
|
|
||||||
className={styles.modalClose}
|
|
||||||
onClick={() => setShowAddModal(false)}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className={styles.modalContent}>
|
|
||||||
{availableUsers.length === 0 ? (
|
|
||||||
<p>{t('Alle Benutzer sind bereits diesem')}</p>
|
|
||||||
) : roleOptions.length === 0 ? (
|
|
||||||
<div className={styles.loadingContainer}>
|
|
||||||
<div className={styles.spinner} />
|
|
||||||
<span>{t('Lade Rollen')}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<FormGeneratorForm
|
|
||||||
attributes={addUserFields}
|
|
||||||
mode="create"
|
|
||||||
onSubmit={handleAddUser}
|
|
||||||
onCancel={() => setShowAddModal(false)}
|
|
||||||
submitButtonText={isSubmitting ? t('Hinzufügen') : t('Hinzufügen')}
|
|
||||||
cancelButtonText={t('Abbrechen')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Edit Roles Modal */}
|
|
||||||
{editingUser && (
|
|
||||||
<div className={styles.modalOverlay}>
|
|
||||||
<div className={styles.modal}>
|
|
||||||
<div className={styles.modalHeader}>
|
|
||||||
<h2 className={styles.modalTitle}>
|
|
||||||
{t('Rollen bearbeiten')}: {editingUser.username}
|
|
||||||
</h2>
|
|
||||||
<button
|
|
||||||
className={styles.modalClose}
|
|
||||||
onClick={() => setEditingUser(null)}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className={styles.modalContent}>
|
|
||||||
<FormGeneratorForm
|
|
||||||
attributes={editRolesFields}
|
|
||||||
data={{ roleIds: editingUser.roleIds }}
|
|
||||||
mode="edit"
|
|
||||||
onSubmit={handleEditRoles}
|
|
||||||
onCancel={() => setEditingUser(null)}
|
|
||||||
submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')}
|
|
||||||
cancelButtonText={t('Abbrechen')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue