Compare commits
6 commits
main
...
feat/tenan
| Author | SHA1 | Date | |
|---|---|---|---|
| f88d1c7719 | |||
| 78ccea8bac | |||
| fa7988b4b6 | |||
| eb0a58aaa7 | |||
| 849efa6ed7 | |||
| dfa36c17ef |
38 changed files with 3504 additions and 1769 deletions
|
|
@ -39,7 +39,7 @@ import { GDPRPage } from './pages/GDPR';
|
|||
import StorePage from './pages/Store';
|
||||
import { IntegrationsOverviewPage } from './pages/IntegrationsOverviewPage';
|
||||
import { FeatureViewPage } from './pages/FeatureView';
|
||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
|
||||
import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminUserRoleTemplatesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminUserAccessOverviewPage, AdminLogsPage, AdminDemoConfigPage } from './pages/admin';
|
||||
import { AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin/wizards';
|
||||
import { PromptsPage, FilesPage, ConnectionsPage } from './pages/basedata';
|
||||
import { BillingDataView, BillingAdmin, BillingMandateView, AdminSubscriptionsPage } from './pages/billing';
|
||||
|
|
@ -206,14 +206,14 @@ function App() {
|
|||
<Route index element={<Navigate to="/admin/access" replace />} />
|
||||
<Route path="mandates" element={<AdminMandatesPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="user-mandates" element={<AdminUserMandatesPage />} />
|
||||
<Route path="user-mandates" element={<Navigate to="/admin/mandates" replace />} />
|
||||
<Route path="access" element={<AccessManagementHub />} />
|
||||
<Route path="feature-instances" element={<AdminFeatureAccessPage />} />
|
||||
<Route path="feature-roles" element={<AdminFeatureRolesPage />} />
|
||||
<Route path="feature-users" element={<AdminFeatureInstanceUsersPage />} />
|
||||
<Route path="invitations" element={<AdminInvitationsPage />} />
|
||||
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
|
||||
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
||||
<Route path="user-role-templates" element={<AdminUserRoleTemplatesPage />} />
|
||||
<Route path="mandate-roles" element={<Navigate to="/admin/user-role-templates" replace />} />
|
||||
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
|
||||
<Route path="billing">
|
||||
<Route index element={<Navigate to="/billing/admin" replace />} />
|
||||
|
|
|
|||
|
|
@ -175,6 +175,16 @@
|
|||
box-shadow: 0 3px 8px rgba(74, 111, 165, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.actionButton.roles {
|
||||
background: linear-gradient(180deg, #6b9a7a 0%, #38a169 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.actionButton.roles:hover {
|
||||
background: linear-gradient(180deg, #38a169 0%, #2f855a 100%);
|
||||
box-shadow: 0 3px 8px rgba(56, 161, 105, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.actionButton.copy {
|
||||
background: linear-gradient(180deg, #8494a7 0%, #6b7b8d 100%);
|
||||
color: white;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,45 @@
|
|||
.chevronBtn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.12s;
|
||||
flex-shrink: 0;
|
||||
border-radius: 3px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
color: var(--color-text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.chevronBtn:hover:not(:disabled) {
|
||||
background: var(--color-primary-light, rgba(74, 111, 165, 0.12));
|
||||
color: var(--color-primary, #4a6fa5);
|
||||
}
|
||||
|
||||
.chevronBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.chevronBtn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--color-bg, #fff), 0 0 0 4px rgba(74, 111, 165, 0.45);
|
||||
}
|
||||
|
||||
.chevronIcon {
|
||||
font-size: 11px;
|
||||
transition: transform 0.32s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.chevronIconExpanded {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.chevronIcon {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import React, { useState } from 'react';
|
||||
import { FaShieldAlt } from 'react-icons/fa';
|
||||
import { useLanguage } from '../../../../providers/language/LanguageContext';
|
||||
import { Popup } from '../../../UiComponents/Popup';
|
||||
import { MandateRolesPermissionsPanel } from '../../../admin/MandateRolesPermissionsPanel';
|
||||
import { mandateDisplayLabel } from '../../../../utils/mandateDisplayUtils';
|
||||
import styles from '../ActionButton.module.css';
|
||||
|
||||
export interface RolesActionButtonProps<T = Record<string, unknown>> {
|
||||
row: T;
|
||||
disabled?: boolean | { disabled: boolean; message?: string };
|
||||
loading?: boolean;
|
||||
className?: string;
|
||||
title?: string;
|
||||
idField?: string;
|
||||
labelField?: string;
|
||||
nameField?: string;
|
||||
}
|
||||
|
||||
export function RolesActionButton<T = Record<string, unknown>>({
|
||||
row,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className = '',
|
||||
title,
|
||||
idField = 'id',
|
||||
labelField = 'label',
|
||||
nameField = 'name',
|
||||
}: RolesActionButtonProps<T>) {
|
||||
const { t } = useLanguage();
|
||||
const [isPopupOpen, setIsPopupOpen] = useState(false);
|
||||
|
||||
const isDisabled = typeof disabled === 'boolean' ? disabled : disabled?.disabled || false;
|
||||
const disabledMessage = typeof disabled === 'object' ? disabled?.message : undefined;
|
||||
|
||||
const rowRecord = row as Record<string, unknown>;
|
||||
const mandateId = String(rowRecord[idField] ?? '');
|
||||
const mandateLabel = mandateDisplayLabel({
|
||||
label: rowRecord[labelField] as string | undefined,
|
||||
name: rowRecord[nameField] as string | undefined,
|
||||
id: mandateId,
|
||||
});
|
||||
|
||||
const handleClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (!isDisabled && !loading && mandateId) {
|
||||
setIsPopupOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
const buttonTitle = title || t('Rollen & Berechtigungen');
|
||||
const finalTitle = isDisabled && disabledMessage ? disabledMessage : buttonTitle;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClick}
|
||||
className={`${styles.actionButton} ${styles.roles} ${loading ? styles.loading : ''} ${isDisabled ? styles.disabled : ''} ${className}`}
|
||||
title={finalTitle}
|
||||
disabled={isDisabled || loading || !mandateId}
|
||||
>
|
||||
<span className={styles.actionIcon}>
|
||||
{loading ? '⏳' : <FaShieldAlt />}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Popup
|
||||
isOpen={isPopupOpen}
|
||||
title={`${t('Rollen-Berechtigungen')}: ${mandateLabel}`}
|
||||
onClose={() => setIsPopupOpen(false)}
|
||||
size="xlarge"
|
||||
closable={true}
|
||||
closeOnBackdropClick={false}
|
||||
>
|
||||
{isPopupOpen && mandateId && (
|
||||
<MandateRolesPermissionsPanel
|
||||
mandateId={mandateId}
|
||||
mandateLabel={mandateLabel}
|
||||
showRoleActions={true}
|
||||
showCreateRole={true}
|
||||
scopeFilter="mandate"
|
||||
showInfoBox={true}
|
||||
/>
|
||||
)}
|
||||
</Popup>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default RolesActionButton;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { RolesActionButton } from './RolesActionButton';
|
||||
export type { RolesActionButtonProps } from './RolesActionButton';
|
||||
|
|
@ -5,6 +5,8 @@ export { ViewActionButton } from './ViewActionButton';
|
|||
export { CopyActionButton } from './CopyActionButton';
|
||||
export { RemoveActionButton } from './RemoveActionButton';
|
||||
export { DownloadActionButton } from './DownloadActionButton';
|
||||
export { RolesActionButton } from './RolesActionButton';
|
||||
export { ExpandActionButton } from './ExpandActionButton';
|
||||
|
||||
// Generic Custom Action Button (for entity-specific actions)
|
||||
export { CustomActionButton } from './CustomActionButton';
|
||||
|
|
@ -16,4 +18,6 @@ export type { ViewActionButtonProps } from './ViewActionButton';
|
|||
export type { CopyActionButtonProps } from './CopyActionButton';
|
||||
export type { RemoveActionButtonProps } from './RemoveActionButton';
|
||||
export type { DownloadActionButtonProps } from './DownloadActionButton';
|
||||
export type { RolesActionButtonProps } from './RolesActionButton';
|
||||
export type { ExpandActionButtonProps } from './ExpandActionButton';
|
||||
export type { CustomActionButtonProps } from './CustomActionButton';
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
DeleteActionButton,
|
||||
ViewActionButton,
|
||||
CopyActionButton,
|
||||
RolesActionButton,
|
||||
CustomActionButton
|
||||
} from '../ActionButtons';
|
||||
import { FaDownload, FaLink, FaPlay } from 'react-icons/fa';
|
||||
|
|
@ -52,7 +53,7 @@ export interface FormGeneratorListProps<T = any> {
|
|||
loading?: boolean;
|
||||
emptyMessage?: string;
|
||||
actionButtons?: {
|
||||
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'connect' | 'play';
|
||||
type: 'edit' | 'delete' | 'download' | 'view' | 'copy' | 'roles' | 'connect' | 'play';
|
||||
onAction?: (row: T) => Promise<void> | void;
|
||||
disabled?: (row: T, hookData?: any) => boolean | { disabled: boolean; message?: string };
|
||||
loading?: (row: T, hookData?: any) => boolean;
|
||||
|
|
@ -858,6 +859,18 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
|||
return <ViewActionButton key={actionIndex} {...baseProps} onView={actionButton.onAction || (() => {})} isViewing={isProcessing} hookData={hookData} />;
|
||||
case 'copy':
|
||||
return <CopyActionButton key={actionIndex} {...baseProps} onCopy={actionButton.onAction} isCopying={isProcessing} contentField={actionButton.contentField} />;
|
||||
case 'roles':
|
||||
return (
|
||||
<RolesActionButton
|
||||
key={actionIndex}
|
||||
row={row}
|
||||
disabled={disabledResult}
|
||||
loading={isLoading}
|
||||
className={actionButton.className}
|
||||
title={actionTitle}
|
||||
idField={actionButton.idField ?? 'id'}
|
||||
/>
|
||||
);
|
||||
case 'connect':
|
||||
return <CustomActionButton
|
||||
key={actionIndex}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import styles from './FormGeneratorTable.module.css';
|
||||
|
||||
export interface ExpandableDetailRowProps {
|
||||
open: boolean;
|
||||
colSpan: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table detail row with smooth expand/collapse (grid 0fr → 1fr).
|
||||
* Stays mounted until the close animation finishes.
|
||||
*/
|
||||
export function ExpandableDetailRow({ open, colSpan, children }: ExpandableDetailRowProps) {
|
||||
const [mounted, setMounted] = useState(open);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
const id = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (openRef.current) setRevealed(true);
|
||||
});
|
||||
});
|
||||
return () => cancelAnimationFrame(id);
|
||||
}
|
||||
setRevealed(false);
|
||||
const fallback = window.setTimeout(() => {
|
||||
if (!openRef.current) setMounted(false);
|
||||
}, 420);
|
||||
return () => window.clearTimeout(fallback);
|
||||
}, [open]);
|
||||
|
||||
const handleTransitionEnd = useCallback((e: React.TransitionEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.propertyName !== 'grid-template-rows') return;
|
||||
if (!openRef.current) {
|
||||
setMounted(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<tr className={styles.expandedDetailRow}>
|
||||
<td
|
||||
colSpan={colSpan}
|
||||
className={`${styles.expandedDetailCell} ${revealed ? styles.expandedDetailCellOpen : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className={`${styles.expandedCollapsible} ${revealed ? styles.expandedCollapsibleOpen : ''}`}
|
||||
onTransitionEnd={handleTransitionEnd}
|
||||
>
|
||||
<div className={styles.expandedCollapsibleInner}>
|
||||
<div className={styles.expandedDetailInner}>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default ExpandableDetailRow;
|
||||
|
|
@ -578,6 +578,79 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tr.expandedParentRow {
|
||||
background: rgba(var(--color-secondary-rgb, 74, 111, 165), 0.06);
|
||||
}
|
||||
|
||||
.expandedDetailRow {
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
}
|
||||
|
||||
.expandedDetailRow:hover {
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.expandedDetailCell {
|
||||
padding: 0 16px;
|
||||
vertical-align: top;
|
||||
border-bottom: 2px solid transparent;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
padding 0.32s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.32s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.expandedDetailCellOpen {
|
||||
padding: 12px 16px 16px;
|
||||
border-bottom-color: var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.expandedCollapsible {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
transition:
|
||||
grid-template-rows 0.36s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.expandedCollapsibleOpen {
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.expandedCollapsibleInner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.expandedDetailInner {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
transform: translateY(-6px);
|
||||
transition: transform 0.36s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.expandedCollapsibleOpen .expandedDetailInner {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.expandedDetailCell,
|
||||
.expandedCollapsible,
|
||||
.expandedDetailInner {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.expandedCollapsibleOpen .expandedDetailInner {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Items that live inside a group — subtle tint + left connector */
|
||||
.tr.groupedItem {
|
||||
border-left: 3px solid color-mix(in srgb, var(--color-primary, #4a6fa5) 35%, transparent);
|
||||
|
|
|
|||
|
|
@ -64,6 +64,8 @@ import {
|
|||
DeleteActionButton,
|
||||
ViewActionButton,
|
||||
CopyActionButton,
|
||||
RolesActionButton,
|
||||
ExpandActionButton,
|
||||
CustomActionButton
|
||||
} from '../ActionButtons';
|
||||
import { formatUnixTimestamp } from '../../../utils/time';
|
||||
|
|
@ -77,6 +79,7 @@ import {
|
|||
isNumberType,
|
||||
} from '../../../utils/attributeTypeMapper';
|
||||
import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
||||
import { ExpandableDetailRow } from './ExpandableDetailRow';
|
||||
import { FaFilter } from 'react-icons/fa';
|
||||
import api from '../../../api';
|
||||
import { PeriodPicker } from '../../PeriodPicker';
|
||||
|
|
@ -264,7 +267,7 @@ export interface FormGeneratorTableProps<T = any> {
|
|||
idField?: string; // Field name for unique row identifier (default: 'id')
|
||||
// Standard action buttons (edit, delete, view, copy, connect, play)
|
||||
actionButtons?: {
|
||||
type: 'edit' | 'delete' | 'view' | 'copy' | 'connect' | 'play';
|
||||
type: 'expand' | 'edit' | 'delete' | 'view' | 'copy' | 'roles' | 'connect' | 'play';
|
||||
onAction?: (row: T) => Promise<void> | void;
|
||||
visible?: (row: T, hookData?: any) => boolean;
|
||||
disabled?: (row: T, hookData?: any) => boolean | { disabled: boolean; message?: string };
|
||||
|
|
@ -355,6 +358,8 @@ export interface FormGeneratorTableProps<T = any> {
|
|||
csvExportContextFilters?: Record<string, unknown>;
|
||||
/** Optional download filename token (sanitized), e.g. group key for nested exports. */
|
||||
csvExportFilenameSuffix?: string;
|
||||
/** Renders content in a full-width row below the data row when expanded (requires hookData.isRowExpanded / toggleExpandedRow). */
|
||||
renderExpandedRow?: (row: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
const _FILTER_PAGE_SIZE = 100;
|
||||
|
|
@ -712,6 +717,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
csvExportQueryParams,
|
||||
csvExportContextFilters,
|
||||
csvExportFilenameSuffix,
|
||||
renderExpandedRow,
|
||||
}: FormGeneratorTableProps<T>) {
|
||||
const { t, currentLanguage: contextLanguage } = useLanguage();
|
||||
// When only onDelete is provided, use it for multi-delete too so Delete stays visible with 2+ selected
|
||||
|
|
@ -2644,78 +2650,123 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
batchActions.length > 0 ||
|
||||
(selectable && selectedIds.size > 0);
|
||||
|
||||
const expandActionConfig = useMemo(
|
||||
() => actionButtons.find((ab) => ab.type === 'expand'),
|
||||
[actionButtons],
|
||||
);
|
||||
|
||||
const _renderDataRow = (row: T, index: number) => {
|
||||
const dataAttributes = getRowDataAttributes ? getRowDataAttributes(row, index) : {};
|
||||
const rowId = _getRowId(row);
|
||||
const isExpanded =
|
||||
!!renderExpandedRow &&
|
||||
!!hookData?.isRowExpanded &&
|
||||
hookData.isRowExpanded(row);
|
||||
const detailColSpan =
|
||||
(selectable ? 1 : 0) + (hasActionColumn ? 1 : 0) + detectedColumns.length;
|
||||
|
||||
const _renderExpandButton = () => {
|
||||
if (!expandActionConfig) return null;
|
||||
const ab = expandActionConfig;
|
||||
if (ab.visible && !ab.visible(row, hookData)) return null;
|
||||
const abTitle = typeof ab.title === 'function' ? ab.title(row) : ab.title;
|
||||
let dis: boolean | { disabled: boolean; message?: string } = false;
|
||||
if (ab.disabled) {
|
||||
dis = ab.disabled(row, hookData);
|
||||
}
|
||||
const isLd = ab.loading ? ab.loading(row, hookData) : false;
|
||||
return (
|
||||
<ExpandActionButton
|
||||
key="expand"
|
||||
row={row}
|
||||
disabled={dis}
|
||||
loading={isLd}
|
||||
className={[compact ? actionBtnStyles.compact : '', ab.className ?? ''].filter(Boolean).join(' ')}
|
||||
title={abTitle}
|
||||
idField={ab.idField ?? idField}
|
||||
hookData={hookData}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={rowId || index}
|
||||
className={`${styles.tr} ${selectedIds.has(rowId) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''}`}
|
||||
onClick={() => onRowClick?.(row, index)}
|
||||
draggable={rowDraggable}
|
||||
onDragStart={(e) => {
|
||||
if (rowDraggable && onRowDragStart) onRowDragStart(e, row);
|
||||
}}
|
||||
onDragEnd={() => {}}
|
||||
{...Object.fromEntries(Object.entries(dataAttributes).map(([k, v]) => [`data-${k}`, v]))}
|
||||
>
|
||||
{selectable && (
|
||||
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
||||
<input type="checkbox"
|
||||
checked={selectedIds.has(rowId)}
|
||||
onChange={() => handleRowSelect(row)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isRowSelectable && !isRowSelectable(row)}
|
||||
title={isRowSelectable && !isRowSelectable(row) ? t('Dieses Element kann nicht ausgewählt werden') : t('Dieses Element auswählen')}
|
||||
style={{ opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1, cursor: isRowSelectable && !isRowSelectable(row) ? 'not-allowed' : 'pointer' }}
|
||||
/>
|
||||
</td>
|
||||
)}
|
||||
{hasActionColumn && (
|
||||
<td className={styles.actionsColumn} style={compact ? undefined : { width: `${currentActionsWidth}px`, minWidth: `${defaultActionsWidth}px` }}>
|
||||
<div ref={(el) => { if (el) actionButtonsRefs.current.set(index, el); else actionButtonsRefs.current.delete(index); }}
|
||||
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}>
|
||||
{actionButtons.map((ab, ai) => {
|
||||
if (ab.visible && !ab.visible(row, hookData)) return null;
|
||||
const abTitle = typeof ab.title === 'function' ? ab.title(row) : ab.title;
|
||||
let dis: boolean | { disabled: boolean; message?: string } = false;
|
||||
if (ab.disabled) { dis = ab.disabled(row, hookData); }
|
||||
else if (row._permissions) {
|
||||
if (ab.type === 'edit' && row._permissions.canUpdate === false) dis = true;
|
||||
else if (ab.type === 'delete' && row._permissions.canDelete === false) dis = true;
|
||||
}
|
||||
const isLd = ab.loading ? ab.loading(row) : false;
|
||||
const isProc = ab.isProcessing ? ab.isProcessing(row) : false;
|
||||
const bp = { row, disabled: dis, loading: isLd, className: [compact ? actionBtnStyles.compact : '', ab.className ?? ''].filter(Boolean).join(' '), title: abTitle, idField: ab.idField ?? 'id', nameField: ab.nameField ?? 'name', typeField: ab.typeField ?? 'type', contentField: ab.contentField ?? 'content', operationName: ab.operationName, loadingStateName: ab.loadingStateName };
|
||||
switch (ab.type) {
|
||||
case 'edit': return <EditActionButton key={`a-${ai}`} {...bp} onEdit={ab.onAction} hookData={hookData} />;
|
||||
case 'delete': return <DeleteActionButton key={`a-${ai}`} {...bp} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
|
||||
case 'view': return <ViewActionButton key={`a-${ai}`} {...bp} onView={ab.onAction || (() => {})} isViewing={isProc} hookData={hookData} />;
|
||||
case 'copy': return <CopyActionButton key={`a-${ai}`} {...bp} onCopy={ab.onAction} isCopying={isProc} contentField={ab.contentField} />;
|
||||
default: return null;
|
||||
}
|
||||
})}
|
||||
{customActions.map((ca) => (
|
||||
<CustomActionButton key={`ca-${ca.id}`} row={row} id={ca.id} icon={ca.icon}
|
||||
onClick={ca.onClick} visible={ca.visible} disabled={ca.disabled}
|
||||
loading={ca.loading} title={ca.title} className={ca.className}
|
||||
hookData={hookData} idField={ca.idField ?? 'id'} />
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
{detectedColumns.map((col) => {
|
||||
const cv = row[col.key];
|
||||
const cCls = col.cellClassName ? col.cellClassName(cv, row) : '';
|
||||
const aStyle = _columnAlignStyle(col);
|
||||
return (
|
||||
<td key={col.key} className={`${styles.td} ${cCls}`.trim()}
|
||||
style={{ width: columnWidths[col.key] || col.width || 150, minWidth: columnWidths[col.key] || col.width || 150, maxWidth: columnWidths[col.key] || col.width || 150, ...aStyle }}>
|
||||
{formatCellValue(cv, col, row)}
|
||||
<React.Fragment key={rowId || index}>
|
||||
<tr
|
||||
className={`${styles.tr} ${selectedIds.has(rowId) ? styles.selected : ''} ${onRowClick ? styles.clickable : ''} ${isExpanded ? styles.expandedParentRow : ''}`}
|
||||
onClick={() => onRowClick?.(row, index)}
|
||||
draggable={rowDraggable}
|
||||
onDragStart={(e) => {
|
||||
if (rowDraggable && onRowDragStart) onRowDragStart(e, row);
|
||||
}}
|
||||
onDragEnd={() => {}}
|
||||
{...Object.fromEntries(Object.entries(dataAttributes).map(([k, v]) => [`data-${k}`, v]))}
|
||||
>
|
||||
{selectable && (
|
||||
<td className={styles.selectColumn} style={{ width: '40px', minWidth: '40px', maxWidth: '40px' }}>
|
||||
<input type="checkbox"
|
||||
checked={selectedIds.has(rowId)}
|
||||
onChange={() => handleRowSelect(row)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isRowSelectable && !isRowSelectable(row)}
|
||||
title={isRowSelectable && !isRowSelectable(row) ? t('Dieses Element kann nicht ausgewählt werden') : t('Dieses Element auswählen')}
|
||||
style={{ opacity: isRowSelectable && !isRowSelectable(row) ? 0.4 : 1, cursor: isRowSelectable && !isRowSelectable(row) ? 'not-allowed' : 'pointer' }}
|
||||
/>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
)}
|
||||
{hasActionColumn && (
|
||||
<td className={styles.actionsColumn} style={compact ? undefined : { width: `${currentActionsWidth}px`, minWidth: `${defaultActionsWidth}px` }}>
|
||||
<div ref={(el) => { if (el) actionButtonsRefs.current.set(index, el); else actionButtonsRefs.current.delete(index); }}
|
||||
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}>
|
||||
{_renderExpandButton()}
|
||||
{actionButtons.map((ab, ai) => {
|
||||
if (ab.type === 'expand') return null;
|
||||
if (ab.visible && !ab.visible(row, hookData)) return null;
|
||||
const abTitle = typeof ab.title === 'function' ? ab.title(row) : ab.title;
|
||||
let dis: boolean | { disabled: boolean; message?: string } = false;
|
||||
if (ab.disabled) { dis = ab.disabled(row, hookData); }
|
||||
else if (row._permissions) {
|
||||
if (ab.type === 'edit' && row._permissions.canUpdate === false) dis = true;
|
||||
else if (ab.type === 'delete' && row._permissions.canDelete === false) dis = true;
|
||||
}
|
||||
const isLd = ab.loading ? ab.loading(row, hookData) : false;
|
||||
const isProc = ab.isProcessing ? ab.isProcessing(row) : false;
|
||||
const bp = { row, disabled: dis, loading: isLd, className: [compact ? actionBtnStyles.compact : '', ab.className ?? ''].filter(Boolean).join(' '), title: abTitle, idField: ab.idField ?? 'id', nameField: ab.nameField ?? 'name', typeField: ab.typeField ?? 'type', contentField: ab.contentField ?? 'content', operationName: ab.operationName, loadingStateName: ab.loadingStateName };
|
||||
switch (ab.type) {
|
||||
case 'edit': return <EditActionButton key={`a-${ai}`} {...bp} onEdit={ab.onAction} hookData={hookData} />;
|
||||
case 'delete': return <DeleteActionButton key={`a-${ai}`} {...bp} containerRef={{ current: actionButtonsRefs.current.get(index) || null }} hookData={hookData} />;
|
||||
case 'view': return <ViewActionButton key={`a-${ai}`} {...bp} onView={ab.onAction || (() => {})} isViewing={isProc} hookData={hookData} />;
|
||||
case 'copy': return <CopyActionButton key={`a-${ai}`} {...bp} onCopy={ab.onAction} isCopying={isProc} contentField={ab.contentField} />;
|
||||
case 'roles': return <RolesActionButton key={`a-${ai}`} row={row} disabled={dis} loading={isLd} className={bp.className} title={abTitle} idField={ab.idField ?? 'id'} />;
|
||||
default: return null;
|
||||
}
|
||||
})}
|
||||
{customActions.map((ca) => (
|
||||
<CustomActionButton key={`ca-${ca.id}`} row={row} id={ca.id} icon={ca.icon}
|
||||
onClick={ca.onClick} visible={ca.visible} disabled={ca.disabled}
|
||||
loading={ca.loading} title={ca.title} className={ca.className}
|
||||
hookData={hookData} idField={ca.idField ?? 'id'} />
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
{detectedColumns.map((col) => {
|
||||
const cv = row[col.key];
|
||||
const cCls = col.cellClassName ? col.cellClassName(cv, row) : '';
|
||||
const aStyle = _columnAlignStyle(col);
|
||||
return (
|
||||
<td key={col.key} className={`${styles.td} ${cCls}`.trim()}
|
||||
style={{ width: columnWidths[col.key] || col.width || 150, minWidth: columnWidths[col.key] || col.width || 150, maxWidth: columnWidths[col.key] || col.width || 150, ...aStyle }}>
|
||||
{formatCellValue(cv, col, row)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
{renderExpandedRow && (
|
||||
<ExpandableDetailRow open={isExpanded} colSpan={detailColSpan}>
|
||||
{renderExpandedRow(row)}
|
||||
</ExpandableDetailRow>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@
|
|||
min-width: 600px;
|
||||
}
|
||||
|
||||
.xlarge {
|
||||
width: min(1400px, 96vw);
|
||||
max-width: 96vw;
|
||||
min-width: min(1100px, 96vw);
|
||||
}
|
||||
|
||||
.fullscreen {
|
||||
width: 95vw;
|
||||
height: 95vh;
|
||||
|
|
@ -186,7 +192,9 @@
|
|||
.content {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Footer section */
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ export interface PopupProps {
|
|||
children: React.ReactNode;
|
||||
footerContent?: React.ReactNode;
|
||||
className?: string;
|
||||
size?: 'small' | 'medium' | 'large' | 'fullscreen';
|
||||
size?: 'small' | 'medium' | 'large' | 'xlarge' | 'fullscreen';
|
||||
closable?: boolean;
|
||||
closeOnBackdropClick?: boolean;
|
||||
closeOnEscape?: boolean;
|
||||
|
|
|
|||
163
src/components/admin/InlineEditableField.tsx
Normal file
163
src/components/admin/InlineEditableField.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import styles from './MandateInfoPanel.module.css';
|
||||
|
||||
export type InlineFieldType = 'text' | 'number' | 'boolean' | 'textarea' | 'email';
|
||||
|
||||
export interface InlineEditableFieldProps {
|
||||
label: string;
|
||||
value: unknown;
|
||||
fieldKey: string;
|
||||
type?: InlineFieldType;
|
||||
editable?: boolean;
|
||||
saving?: boolean;
|
||||
onSave: (fieldKey: string, value: unknown) => Promise<void>;
|
||||
}
|
||||
|
||||
function displayValue(value: unknown, type: InlineFieldType, t: (k: string) => string): string {
|
||||
if (type === 'boolean') {
|
||||
if (value === true) return t('Ja');
|
||||
if (value === false) return t('Nein');
|
||||
return '—';
|
||||
}
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export const InlineEditableField: React.FC<
|
||||
InlineEditableFieldProps & { t: (key: string) => string }
|
||||
> = ({
|
||||
label,
|
||||
value,
|
||||
fieldKey,
|
||||
type = 'text',
|
||||
editable = false,
|
||||
saving = false,
|
||||
onSave,
|
||||
t,
|
||||
}) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
if (type !== 'boolean' && inputRef.current instanceof HTMLInputElement) {
|
||||
inputRef.current.select();
|
||||
}
|
||||
}
|
||||
}, [editing, type]);
|
||||
|
||||
const startEdit = () => {
|
||||
if (!editable || saving || type === 'boolean') return;
|
||||
setDraft(value === null || value === undefined ? '' : String(value));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const commit = async () => {
|
||||
if (!editing) return;
|
||||
setEditing(false);
|
||||
let next: unknown = draft;
|
||||
if (type === 'number') {
|
||||
const n = Number(draft);
|
||||
if (Number.isNaN(n)) return;
|
||||
next = n;
|
||||
}
|
||||
if (String(value ?? '') === String(next ?? '')) return;
|
||||
await onSave(fieldKey, next);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
setEditing(false);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
const handleBooleanToggle = async () => {
|
||||
if (!editable || saving) return;
|
||||
const next = value !== true;
|
||||
await onSave(fieldKey, next);
|
||||
};
|
||||
|
||||
const shown = displayValue(value, type, t);
|
||||
const muted = shown === '—';
|
||||
|
||||
if (type === 'boolean') {
|
||||
return (
|
||||
<>
|
||||
<dt className={styles.fieldLabel}>{label}</dt>
|
||||
<dd className={styles.fieldValue}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.boolToggle} ${editable ? styles.editableValue : ''} ${saving ? styles.saving : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void handleBooleanToggle();
|
||||
}}
|
||||
disabled={!editable || saving}
|
||||
>
|
||||
{saving ? t('Speichern…') : shown}
|
||||
</button>
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<dt className={styles.fieldLabel}>{label}</dt>
|
||||
<dd className={styles.fieldValue}>
|
||||
{editing ? (
|
||||
type === 'textarea' ? (
|
||||
<textarea
|
||||
ref={inputRef as React.RefObject<HTMLTextAreaElement>}
|
||||
className={styles.inlineInput}
|
||||
rows={3}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => void commit()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') cancel();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
ref={inputRef as React.RefObject<HTMLInputElement>}
|
||||
type={type === 'email' ? 'email' : type === 'number' ? 'number' : 'text'}
|
||||
className={styles.inlineInput}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => void commit()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') void commit();
|
||||
if (e.key === 'Escape') cancel();
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<span
|
||||
role={editable ? 'button' : undefined}
|
||||
tabIndex={editable ? 0 : undefined}
|
||||
className={`${styles.valueClickable} ${editable ? styles.editableValue : ''} ${muted ? styles.fieldValueMuted : ''} ${saving ? styles.saving : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
startEdit();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (editable && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
startEdit();
|
||||
}
|
||||
}}
|
||||
title={editable ? t('Klicken zum Bearbeiten') : undefined}
|
||||
>
|
||||
{saving ? t('Speichern…') : shown}
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InlineEditableField;
|
||||
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;
|
||||
30
src/components/admin/MandateExpandDashboard.module.css
Normal file
30
src/components/admin/MandateExpandDashboard.module.css
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
.dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 3fr;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.panel {
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg, #fff);
|
||||
padding: 14px 16px;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.panelUsers {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
52
src/components/admin/MandateExpandDashboard.tsx
Normal file
52
src/components/admin/MandateExpandDashboard.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* MandateExpandDashboard — two-panel layout inside expanded mandate table row.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import type { Mandate } from '../../hooks/useMandates';
|
||||
import { MandateInfoPanel } from './MandateInfoPanel';
|
||||
import { MandateUsersPanel } from './MandateUsersPanel';
|
||||
import styles from './MandateExpandDashboard.module.css';
|
||||
|
||||
export interface MandateExpandDashboardProps {
|
||||
mandate: Mandate;
|
||||
canUpdate: boolean;
|
||||
fetchMandateById: (id: string) => Promise<Mandate | null>;
|
||||
handleUpdate: (mandateId: string, updateData: Partial<Mandate>) => Promise<boolean>;
|
||||
onMandateUpdated?: () => void;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
export const MandateExpandDashboard: React.FC<MandateExpandDashboardProps> = ({
|
||||
mandate,
|
||||
canUpdate,
|
||||
fetchMandateById,
|
||||
handleUpdate,
|
||||
onMandateUpdated,
|
||||
refreshKey,
|
||||
}) => {
|
||||
return (
|
||||
<div className={styles.dashboard}>
|
||||
<div className={styles.panel}>
|
||||
<MandateInfoPanel
|
||||
mandateId={mandate.id}
|
||||
mandateLabel={mandate.label || mandate.name}
|
||||
canUpdate={canUpdate}
|
||||
fetchMandateById={fetchMandateById}
|
||||
handleUpdate={handleUpdate}
|
||||
onMandateUpdated={onMandateUpdated}
|
||||
refreshKey={refreshKey}
|
||||
/>
|
||||
</div>
|
||||
<div className={`${styles.panel} ${styles.panelUsers}`}>
|
||||
<MandateUsersPanel
|
||||
mandateId={mandate.id}
|
||||
embedded
|
||||
embeddedInDashboard
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MandateExpandDashboard;
|
||||
194
src/components/admin/MandateInfoPanel.module.css
Normal file
194
src/components/admin/MandateInfoPanel.module.css
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
.panelInner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #0f172a);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.headerSubtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-family: var(--font-family-mono, monospace);
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
margin: 0;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-secondary, #64748b);
|
||||
padding-bottom: 4px;
|
||||
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.fieldGrid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 38%) minmax(0, 1fr);
|
||||
gap: 6px 12px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.fieldValue {
|
||||
color: var(--text-primary, #0f172a);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.fieldValueMuted {
|
||||
color: var(--text-secondary, #94a3b8);
|
||||
}
|
||||
|
||||
.valueClickable {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.editableValue {
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.editableValue:hover {
|
||||
background: var(--color-primary-light, rgba(74, 111, 165, 0.1));
|
||||
}
|
||||
|
||||
.saving {
|
||||
opacity: 0.6;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.inlineInput {
|
||||
width: 100%;
|
||||
font-size: 0.8125rem;
|
||||
font-family: var(--font-family);
|
||||
padding: 4px 6px;
|
||||
border: 1px solid var(--color-primary, #4a6fa5);
|
||||
border-radius: 4px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.boolToggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.addressBlock {
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.addressPre {
|
||||
margin: 0;
|
||||
font-family: var(--font-family);
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
color: var(--text-primary, #0f172a);
|
||||
}
|
||||
|
||||
.addressEditBox {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.addressDoneBtn {
|
||||
align-self: flex-end;
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
background: var(--color-bg, #fff);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.addressDoneBtn:hover {
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
}
|
||||
|
||||
.systemHint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
padding: 6px 8px;
|
||||
background: var(--warning-bg, #fffbeb);
|
||||
border: 1px solid var(--warning-color, #d69e2e);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.billingWarning {
|
||||
font-size: 0.75rem;
|
||||
color: var(--warning-color, #d69e2e);
|
||||
padding: 6px 8px;
|
||||
background: var(--warning-bg, #fffbeb);
|
||||
border: 1px solid var(--warning-color, #d69e2e);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.loadingWrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8125rem;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--color-border, #e2e8f0);
|
||||
border-top-color: var(--primary-color, #f25843);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
329
src/components/admin/MandateInfoPanel.tsx
Normal file
329
src/components/admin/MandateInfoPanel.tsx
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
/**
|
||||
* MandateInfoPanel — inline-editable mandate + billing summary (left expand panel).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import type { Mandate } from '../../hooks/useMandates';
|
||||
import { useMandateFormAttributes } from '../../hooks/useMandates';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchSettingsAdmin, updateSettingsAdmin, type BillingSettingsUpdate } from '../../api/billingApi';
|
||||
import {
|
||||
mergeBillingIntoMandateFormData,
|
||||
getMandateBillingFormAttributes,
|
||||
MANDATE_INVOICE_FIELD_NAMES,
|
||||
formatMandateInvoiceAddress,
|
||||
isMandateBillingField,
|
||||
mandateBillingFieldNames,
|
||||
} from '../../utils/mandateBillingFormMerge';
|
||||
import { InlineEditableField, type InlineFieldType } from './InlineEditableField';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import styles from './MandateInfoPanel.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||
import { getUserDataCache } from '../../utils/userCache';
|
||||
|
||||
const STAMMDATEN_FIELDS = ['name', 'label', 'enabled', 'isSystem', 'mfaRequired'] as const;
|
||||
|
||||
const FIELD_TYPES: Record<string, InlineFieldType> = {
|
||||
name: 'text',
|
||||
label: 'text',
|
||||
enabled: 'boolean',
|
||||
isSystem: 'boolean',
|
||||
mfaRequired: 'boolean',
|
||||
warningThresholdPercent: 'number',
|
||||
notifyOnWarning: 'boolean',
|
||||
notifyEmails: 'textarea',
|
||||
invoiceEmail: 'email',
|
||||
};
|
||||
|
||||
const MANDATE_ADMIN_EDITABLE = new Set(['label', 'warningThresholdPercent', 'notifyOnWarning', 'notifyEmails']);
|
||||
|
||||
export interface MandateInfoPanelProps {
|
||||
mandateId: string;
|
||||
mandateLabel?: string;
|
||||
canUpdate: boolean;
|
||||
fetchMandateById: (id: string) => Promise<Mandate | null>;
|
||||
handleUpdate: (mandateId: string, updateData: Partial<Mandate>) => Promise<boolean>;
|
||||
onMandateUpdated?: () => void;
|
||||
refreshKey?: number;
|
||||
}
|
||||
|
||||
function isFieldEditable(
|
||||
fieldName: string,
|
||||
canUpdate: boolean,
|
||||
isPlatformAdmin: boolean,
|
||||
isSystemMandate: boolean,
|
||||
): boolean {
|
||||
if (!canUpdate) return false;
|
||||
if (fieldName === 'isSystem') return false;
|
||||
if (isSystemMandate && fieldName === 'name') return false;
|
||||
if (!isPlatformAdmin) {
|
||||
return MANDATE_ADMIN_EDITABLE.has(fieldName);
|
||||
}
|
||||
return fieldName !== 'isSystem' && fieldName !== 'id' && fieldName !== 'deletedAt';
|
||||
}
|
||||
|
||||
function InfoSection({
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<h4 className={styles.sectionTitle}>{title}</h4>
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export const MandateInfoPanel: React.FC<MandateInfoPanelProps> = ({
|
||||
mandateId,
|
||||
mandateLabel,
|
||||
canUpdate,
|
||||
fetchMandateById,
|
||||
handleUpdate,
|
||||
onMandateUpdated,
|
||||
refreshKey = 0,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const { showError, showWarning } = useToast();
|
||||
const { formAttributes } = useMandateFormAttributes();
|
||||
const isPlatformAdmin = getUserDataCache()?.isPlatformAdmin === true;
|
||||
|
||||
const [data, setData] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [billingWarning, setBillingWarning] = useState<string | null>(null);
|
||||
const [savingField, setSavingField] = useState<string | null>(null);
|
||||
const [editingAddress, setEditingAddress] = useState(false);
|
||||
|
||||
const labelMap = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const attr of formAttributes) {
|
||||
map.set(attr.name, attr.label || attr.name);
|
||||
}
|
||||
for (const attr of getMandateBillingFormAttributes()) {
|
||||
map.set(attr.name, attr.label || attr.name);
|
||||
}
|
||||
return map;
|
||||
}, [formAttributes]);
|
||||
|
||||
const loadDetail = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setBillingWarning(null);
|
||||
try {
|
||||
const fullMandate = await fetchMandateById(mandateId);
|
||||
if (!fullMandate) {
|
||||
setData(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const settings = await fetchSettingsAdmin(request, mandateId);
|
||||
setData(mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, settings));
|
||||
} catch {
|
||||
setData(mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, null));
|
||||
setBillingWarning(t('Abrechnungseinstellungen konnten nicht geladen werden.'));
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [mandateId, fetchMandateById, request, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadDetail();
|
||||
}, [loadDetail, refreshKey]);
|
||||
|
||||
const isSystemMandate = Boolean(data?.isSystem);
|
||||
|
||||
const canEditInvoice = isPlatformAdmin && canUpdate;
|
||||
|
||||
const saveField = useCallback(
|
||||
async (fieldName: string, newValue: unknown) => {
|
||||
setSavingField(fieldName);
|
||||
try {
|
||||
if (isMandateBillingField(fieldName)) {
|
||||
const billingUpdate: BillingSettingsUpdate = {};
|
||||
if (fieldName === 'warningThresholdPercent') {
|
||||
const n = Number(newValue);
|
||||
if (Number.isNaN(n)) {
|
||||
showError(t('Fehler'), t('Ungültiger Wert'));
|
||||
return;
|
||||
}
|
||||
billingUpdate.warningThresholdPercent = n;
|
||||
} else if (fieldName === 'notifyOnWarning') {
|
||||
billingUpdate.notifyOnWarning = Boolean(newValue);
|
||||
} else if (fieldName === 'notifyEmails') {
|
||||
const emails =
|
||||
typeof newValue === 'string'
|
||||
? newValue.split(/[\n,;]+/).map((s) => s.trim()).filter(Boolean)
|
||||
: [];
|
||||
billingUpdate.notifyEmails = emails;
|
||||
}
|
||||
await updateSettingsAdmin(request, mandateId, billingUpdate);
|
||||
} else {
|
||||
const ok = await handleUpdate(mandateId, { [fieldName]: newValue } as Partial<Mandate>);
|
||||
if (!ok) {
|
||||
showWarning(t('Fehler'), t('Speichern fehlgeschlagen.'));
|
||||
await loadDetail();
|
||||
return;
|
||||
}
|
||||
}
|
||||
setData((prev) => (prev ? { ...prev, [fieldName]: newValue } : prev));
|
||||
onMandateUpdated?.();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
showError(t('Fehler'), t('Speichern fehlgeschlagen.'));
|
||||
await loadDetail();
|
||||
} finally {
|
||||
setSavingField(null);
|
||||
}
|
||||
},
|
||||
[mandateId, request, handleUpdate, loadDetail, onMandateUpdated, showError, showWarning, t],
|
||||
);
|
||||
|
||||
const displayTitle =
|
||||
mandateLabel ||
|
||||
(data
|
||||
? mandateDisplayLabel({
|
||||
label: data.label as string | undefined,
|
||||
name: data.name as string | undefined,
|
||||
id: mandateId,
|
||||
})
|
||||
: mandateId);
|
||||
|
||||
const formattedAddress = data ? formatMandateInvoiceAddress(data) : null;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.loadingWrap}>
|
||||
<div className={styles.spinner} />
|
||||
<span>{t('Laden…')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return (
|
||||
<div className={styles.loadingWrap}>
|
||||
<span>{t('Mandant konnte nicht geladen werden.')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panelInner}>
|
||||
<div className={styles.header}>
|
||||
<div>
|
||||
<h3 className={styles.headerTitle}>{t('Mandant')}</h3>
|
||||
<p className={styles.headerSubtitle}>{displayTitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{billingWarning && <div className={styles.billingWarning}>{billingWarning}</div>}
|
||||
|
||||
{Boolean(data.isSystem) && (
|
||||
<div className={styles.systemHint}>
|
||||
<strong>{t('System-Mandant')}</strong> — {t('Kurzzeichen ist schreibgeschützt.')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<InfoSection title={t('Stammdaten')}>
|
||||
<dl className={styles.fieldGrid}>
|
||||
{STAMMDATEN_FIELDS.map((fieldName) => (
|
||||
<InlineEditableField
|
||||
key={fieldName}
|
||||
t={t}
|
||||
label={labelMap.get(fieldName) || fieldName}
|
||||
value={data[fieldName]}
|
||||
fieldKey={fieldName}
|
||||
type={FIELD_TYPES[fieldName] ?? 'text'}
|
||||
editable={isFieldEditable(fieldName, canUpdate, isPlatformAdmin, isSystemMandate)}
|
||||
saving={savingField === fieldName}
|
||||
onSave={saveField}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
</InfoSection>
|
||||
|
||||
<InfoSection title={t('Rechnungsadresse')}>
|
||||
{editingAddress && canEditInvoice ? (
|
||||
<div className={styles.addressEditBox}>
|
||||
<dl className={styles.fieldGrid}>
|
||||
{MANDATE_INVOICE_FIELD_NAMES.map((fieldName) => (
|
||||
<InlineEditableField
|
||||
key={fieldName}
|
||||
t={t}
|
||||
label={labelMap.get(fieldName) || fieldName}
|
||||
value={data[fieldName]}
|
||||
fieldKey={fieldName}
|
||||
type={FIELD_TYPES[fieldName] ?? 'text'}
|
||||
editable
|
||||
saving={savingField === fieldName}
|
||||
onSave={saveField}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.addressDoneBtn}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditingAddress(false);
|
||||
}}
|
||||
>
|
||||
{t('Fertig')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
role={canEditInvoice ? 'button' : undefined}
|
||||
tabIndex={canEditInvoice ? 0 : undefined}
|
||||
className={`${styles.addressBlock} ${canEditInvoice ? styles.editableValue : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (canEditInvoice) setEditingAddress(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (canEditInvoice && (e.key === 'Enter' || e.key === ' ')) {
|
||||
e.preventDefault();
|
||||
setEditingAddress(true);
|
||||
}
|
||||
}}
|
||||
title={canEditInvoice ? t('Klicken zum Bearbeiten') : undefined}
|
||||
>
|
||||
{formattedAddress ? (
|
||||
<pre className={styles.addressPre}>{formattedAddress}</pre>
|
||||
) : (
|
||||
<span className={styles.fieldValueMuted}>
|
||||
{canEditInvoice ? t('Klicken, um Adresse zu erfassen') : '—'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</InfoSection>
|
||||
|
||||
<InfoSection title={t('Abrechnung')}>
|
||||
<dl className={styles.fieldGrid}>
|
||||
{mandateBillingFieldNames.map((fieldName) => (
|
||||
<InlineEditableField
|
||||
key={fieldName}
|
||||
t={t}
|
||||
label={labelMap.get(fieldName) || fieldName}
|
||||
value={data[fieldName]}
|
||||
fieldKey={fieldName}
|
||||
type={FIELD_TYPES[fieldName] ?? 'text'}
|
||||
editable={isFieldEditable(fieldName, canUpdate, isPlatformAdmin, isSystemMandate)}
|
||||
saving={savingField === fieldName}
|
||||
onSave={saveField}
|
||||
/>
|
||||
))}
|
||||
</dl>
|
||||
</InfoSection>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MandateInfoPanel;
|
||||
243
src/components/admin/MandateRolesPermissionsPanel.module.css
Normal file
243
src/components/admin/MandateRolesPermissionsPanel.module.css
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
/* Table-style role list: one row per role card header */
|
||||
.rolesTable {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rolesTableHead,
|
||||
.roleHeaderTable {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 0.75rem 1rem;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rolesTableHead {
|
||||
padding: 0.5rem 1.25rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary, #666);
|
||||
border-bottom: 1px solid var(--border-color, #e0e0e0);
|
||||
background: var(--bg-tertiary, #f8f9fa);
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.roleHeaderTable {
|
||||
grid-template-columns:
|
||||
24px
|
||||
minmax(100px, 0.9fr)
|
||||
minmax(140px, 2fr)
|
||||
minmax(120px, 0.75fr)
|
||||
minmax(64px, 0.35fr);
|
||||
}
|
||||
|
||||
.roleHeaderTableWithActions {
|
||||
grid-template-columns:
|
||||
24px
|
||||
minmax(100px, 0.9fr)
|
||||
minmax(140px, 2fr)
|
||||
minmax(120px, 0.75fr)
|
||||
minmax(64px, 0.35fr)
|
||||
72px;
|
||||
}
|
||||
|
||||
.rolesTableHeadWithActions {
|
||||
grid-template-columns:
|
||||
24px
|
||||
minmax(100px, 0.9fr)
|
||||
minmax(140px, 2fr)
|
||||
minmax(120px, 0.75fr)
|
||||
minmax(64px, 0.35fr)
|
||||
72px;
|
||||
}
|
||||
|
||||
.roleHeaderTableRow {
|
||||
padding: 0.75rem 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.roleHeaderTableRow:hover {
|
||||
background: var(--bg-tertiary, #f8f9fa);
|
||||
}
|
||||
|
||||
.cellExpand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cellLabel {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cellDescription {
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 0.875rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cellScope,
|
||||
.cellUsers {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary, #666);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cellScope strong,
|
||||
.cellUsers strong {
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.roleHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.roleActionBtn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 5px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.roleActionBtn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.roleActionBtnEdit {
|
||||
background: linear-gradient(180deg, #5a82b5 0%, var(--color-secondary, #4A6FA5) 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.roleActionBtnDelete {
|
||||
background: linear-gradient(180deg, #8494a7 0%, #6b7b8d 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.roleActionBtnDelete:hover:not(:disabled) {
|
||||
background: linear-gradient(180deg, #d44040 0%, var(--color-red, #C53030) 100%);
|
||||
}
|
||||
|
||||
.panelRoot {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.panelHeaderRow {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panelInfoBox {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
align-items: flex-start;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.panelCreateButton {
|
||||
flex-shrink: 0;
|
||||
align-self: flex-start;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.createTemplateField {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.createTemplateLabel {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.createTemplateSelect {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg-secondary, #fff);
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.createTemplateHint {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #666);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.panelInfoBoxIcon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
|
||||
.panelInfoBoxText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.roleContentInfoBox {
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.roleContentInfoBoxText {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.roleCardTable {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.roleCardTable .roleHeaderTableRow {
|
||||
border: none;
|
||||
}
|
||||
574
src/components/admin/MandateRolesPermissionsPanel.tsx
Normal file
574
src/components/admin/MandateRolesPermissionsPanel.tsx
Normal file
|
|
@ -0,0 +1,574 @@
|
|||
/**
|
||||
* MandateRolesPermissionsPanel
|
||||
*
|
||||
* Expandable role list with AccessRulesEditor per role.
|
||||
* Used in RolesActionButton popup on Admin Mandates.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
useMandateRoles,
|
||||
type Role,
|
||||
type RoleUpdate,
|
||||
} from '../../hooks/useMandateRoles';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { AccessRulesEditor } from '../AccessRules';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../FormGenerator/FormGeneratorForm';
|
||||
import { Popup } from '../UiComponents/Popup';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import {
|
||||
FaUserShield,
|
||||
FaSync,
|
||||
FaChevronDown,
|
||||
FaChevronRight,
|
||||
FaPlus,
|
||||
} from 'react-icons/fa';
|
||||
import { MdModeEdit } from 'react-icons/md';
|
||||
import { IoIosTrash } from 'react-icons/io';
|
||||
import adminStyles from '../../pages/admin/Admin.module.css';
|
||||
import panelStyles from './MandateRolesPermissionsPanel.module.css';
|
||||
|
||||
export interface MandateRolesPermissionsPanelProps {
|
||||
mandateId: string;
|
||||
mandateLabel?: string;
|
||||
/** When false, hide per-role edit/delete (e.g. standalone permissions page). Default true. */
|
||||
showRoleActions?: boolean;
|
||||
/** Backend scope filter. Default mandate-only for mandates popup. */
|
||||
scopeFilter?: 'all' | 'mandate' | 'global';
|
||||
/** Show info box above role list. Default true when showRoleActions, else from parent. */
|
||||
showInfoBox?: boolean;
|
||||
/** Increment to trigger refetch (e.g. parent refresh button). */
|
||||
refreshToken?: number;
|
||||
/** Show «Neue Rolle» button and create flow. Default: same as showRoleActions. */
|
||||
showCreateRole?: boolean;
|
||||
}
|
||||
|
||||
const SCOPE_FILTER_DEFAULT: MandateRolesPermissionsPanelProps['scopeFilter'] = 'mandate';
|
||||
|
||||
function getTextValue(value: string | Record<string, string> | undefined): string {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
return Object.values(value).find(v => !!v) || '';
|
||||
}
|
||||
|
||||
function isTemplateRole(role: Role): boolean {
|
||||
return !!role.isSystemRole || !role.mandateId;
|
||||
}
|
||||
|
||||
export const MandateRolesPermissionsPanel: React.FC<MandateRolesPermissionsPanelProps> = ({
|
||||
mandateId,
|
||||
showRoleActions = true,
|
||||
scopeFilter = SCOPE_FILTER_DEFAULT,
|
||||
showInfoBox,
|
||||
refreshToken = 0,
|
||||
showCreateRole,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const { showError, showWarning } = useToast();
|
||||
const {
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
fetchRoles,
|
||||
fetchTemplatesOnly,
|
||||
createRoleFromTemplate,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
} = useMandateRoles();
|
||||
|
||||
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [deletingRoleId, setDeletingRoleId] = useState<string | null>(null);
|
||||
const [userRoleTemplates, setUserRoleTemplates] = useState<Role[]>([]);
|
||||
const [templatesLoading, setTemplatesLoading] = useState(false);
|
||||
const [selectedTemplateId, setSelectedTemplateId] = useState('');
|
||||
const [createFormData, setCreateFormData] = useState<Record<string, unknown>>({});
|
||||
|
||||
const shouldShowInfoBox = showInfoBox ?? showRoleActions;
|
||||
const shouldShowCreateRole = showCreateRole ?? showRoleActions;
|
||||
const needsRoleFormAttributes = showRoleActions || shouldShowCreateRole;
|
||||
|
||||
const loadRoles = useCallback(async () => {
|
||||
if (!mandateId) return;
|
||||
await fetchRoles(mandateId, { scopeFilter });
|
||||
}, [mandateId, scopeFilter, fetchRoles]);
|
||||
|
||||
useEffect(() => {
|
||||
loadRoles();
|
||||
}, [loadRoles, refreshToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (needsRoleFormAttributes) {
|
||||
fetchAttributes(request, 'RoleView')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}
|
||||
}, [request, needsRoleFormAttributes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showCreateModal || !shouldShowCreateRole) return;
|
||||
setTemplatesLoading(true);
|
||||
// fetchTemplatesOnly makes a direct API call without touching the shared
|
||||
// `roles` state, so the displayed mandate roles are never clobbered.
|
||||
fetchTemplatesOnly()
|
||||
.then(setUserRoleTemplates)
|
||||
.catch(() => setUserRoleTemplates([]))
|
||||
.finally(() => setTemplatesLoading(false));
|
||||
}, [showCreateModal, shouldShowCreateRole, fetchTemplatesOnly]);
|
||||
|
||||
const existingMandateRoleLabels = useMemo(
|
||||
() => new Set(roles.map(r => r.roleLabel)),
|
||||
[roles],
|
||||
);
|
||||
|
||||
const closeCreateModal = () => {
|
||||
if (isSubmitting) return;
|
||||
setShowCreateModal(false);
|
||||
setSelectedTemplateId('');
|
||||
setCreateFormData({});
|
||||
};
|
||||
|
||||
const handleTemplateSelect = (templateId: string) => {
|
||||
setSelectedTemplateId(templateId);
|
||||
const template = userRoleTemplates.find(t => t.id === templateId);
|
||||
if (template) {
|
||||
setCreateFormData({
|
||||
roleLabel: template.roleLabel,
|
||||
description: template.description ?? {},
|
||||
});
|
||||
} else {
|
||||
setCreateFormData({});
|
||||
}
|
||||
};
|
||||
|
||||
const scopeTypeLabel = useCallback(
|
||||
(scopeType?: Role['scopeType']) => {
|
||||
switch (scopeType) {
|
||||
case 'system':
|
||||
return t('System-Template');
|
||||
case 'global':
|
||||
return t('Template');
|
||||
case 'mandate':
|
||||
return t('Mandant');
|
||||
default:
|
||||
return '—';
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
|
||||
return backendAttributes
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({ ...attr })) as AttributeDefinition[];
|
||||
}, [backendAttributes]);
|
||||
|
||||
const editFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
|
||||
return backendAttributes
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({
|
||||
...attr,
|
||||
readonly: attr.name === 'roleLabel' ? true : attr.readonly,
|
||||
})) as AttributeDefinition[];
|
||||
}, [backendAttributes]);
|
||||
|
||||
const handleCreateRoleFromTemplate = async (data: {
|
||||
roleLabel: string;
|
||||
description?: Record<string, string>;
|
||||
}) => {
|
||||
if (!mandateId) return;
|
||||
if (!selectedTemplateId) {
|
||||
showError(t('Fehler'), t('Bitte eine Rollen-Vorlage wählen'));
|
||||
return;
|
||||
}
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createRoleFromTemplate(selectedTemplateId, mandateId, {
|
||||
roleLabel: data.roleLabel.toLowerCase().replace(/\s+/g, '_'),
|
||||
description: data.description,
|
||||
});
|
||||
if (result.success) {
|
||||
closeCreateModal();
|
||||
await loadRoles();
|
||||
} else {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Rolle aus Vorlage'));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('Fehler beim Erstellen der Rolle aus Vorlage');
|
||||
showError(t('Fehler'), message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleRole = (roleId: string) => {
|
||||
setExpandedRoleId(prev => (prev === roleId ? null : roleId));
|
||||
};
|
||||
|
||||
const handleEditRole = async (data: RoleUpdate) => {
|
||||
if (!editingRole) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const updateData: RoleUpdate = {
|
||||
roleLabel: data.roleLabel,
|
||||
description: data.description,
|
||||
};
|
||||
const result = await updateRole(editingRole.id, updateData);
|
||||
if (result.success) {
|
||||
setEditingRole(null);
|
||||
await loadRoles();
|
||||
} else {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Rolle'));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('Fehler beim Aktualisieren der Rolle');
|
||||
showError(t('Fehler'), message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRole = async (role: Role) => {
|
||||
if (role.isSystemRole) {
|
||||
showWarning(t('Nicht erlaubt'), t('System-Rollen können nicht gelöscht werden.'));
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(t('Rolle «{label}» wirklich löschen?', { label: role.roleLabel }))) {
|
||||
return;
|
||||
}
|
||||
setDeletingRoleId(role.id);
|
||||
try {
|
||||
const result = await deleteRole(role.id);
|
||||
if (result.success) {
|
||||
if (expandedRoleId === role.id) {
|
||||
setExpandedRoleId(null);
|
||||
}
|
||||
await loadRoles();
|
||||
} else {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Löschen der Rolle'));
|
||||
}
|
||||
} finally {
|
||||
setDeletingRoleId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={adminStyles.errorContainer}>
|
||||
<span className={adminStyles.errorIcon}>⚠️</span>
|
||||
<p className={adminStyles.errorMessage}>
|
||||
{t('Fehler beim Laden')}: {error}
|
||||
</p>
|
||||
<button type="button" className={adminStyles.secondaryButton} onClick={() => loadRoles()}>
|
||||
<FaSync /> {t('Erneut versuchen')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={panelStyles.panelRoot}>
|
||||
{(shouldShowInfoBox || shouldShowCreateRole) && (
|
||||
<div className={panelStyles.panelHeaderRow}>
|
||||
{shouldShowInfoBox && (
|
||||
<div className={`${adminStyles.infoBox} ${panelStyles.panelInfoBox}`}>
|
||||
<FaUserShield
|
||||
className={panelStyles.panelInfoBoxIcon}
|
||||
style={{ marginRight: '0.5rem', color: 'var(--text-secondary, #666)' }}
|
||||
/>
|
||||
<span className={panelStyles.panelInfoBoxText}>
|
||||
{t('Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.')}{' '}
|
||||
<strong>{t('Template-Rollen')}</strong>{' '}
|
||||
{t('sind schreibgeschützt – Änderungen an Templates wirken sich nur auf neu erstellte Mandanten aus.')}{' '}
|
||||
<strong>{t('Mandanten-Rollen')}</strong> {t('sind direkt bearbeitbar.')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{shouldShowCreateRole && (
|
||||
<button
|
||||
type="button"
|
||||
className={`${adminStyles.primaryButton} ${panelStyles.panelCreateButton}`}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaPlus /> {t('Neue Rolle')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<div className={adminStyles.loadingContainer}>
|
||||
<div className={adminStyles.spinner} />
|
||||
<span>{t('Lade Rollen')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && roles.length === 0 && (
|
||||
<div className={adminStyles.emptyState}>
|
||||
<FaUserShield className={adminStyles.emptyIcon} />
|
||||
<p>{t('Keine Rollen gefunden')}</p>
|
||||
<p className={adminStyles.emptyHint}>
|
||||
{scopeFilter === 'mandate'
|
||||
? t('Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.')
|
||||
: scopeFilter === 'global'
|
||||
? t('Es gibt noch keine Rollentemplates')
|
||||
: t('Es gibt noch keine Rollen')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && roles.length > 0 && (
|
||||
<div className={panelStyles.rolesTable}>
|
||||
<div
|
||||
className={`${panelStyles.rolesTableHead} ${
|
||||
showRoleActions ? panelStyles.rolesTableHeadWithActions : ''
|
||||
}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span />
|
||||
<span>{t('Bezeichnung')}</span>
|
||||
<span>{t('Beschreibung')}</span>
|
||||
<span>{t('Geltungsbereich')}</span>
|
||||
<span>{t('Benutzer')}</span>
|
||||
{showRoleActions && <span>{t('Aktionen')}</span>}
|
||||
</div>
|
||||
|
||||
{roles.map(role => {
|
||||
const systemLocked = !!role.isSystemRole;
|
||||
const isExpanded = expandedRoleId === role.id;
|
||||
const headerGridClass = showRoleActions
|
||||
? panelStyles.roleHeaderTableWithActions
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={role.id}
|
||||
className={`${adminStyles.roleCard} ${panelStyles.roleCardTable}`}
|
||||
>
|
||||
<div
|
||||
className={`${panelStyles.roleHeaderTable} ${headerGridClass} ${panelStyles.roleHeaderTableRow}`}
|
||||
onClick={() => toggleRole(role.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
toggleRole(role.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={`${adminStyles.expandIcon} ${panelStyles.cellExpand}`}>
|
||||
{isExpanded ? <FaChevronDown /> : <FaChevronRight />}
|
||||
</span>
|
||||
<span className={panelStyles.cellLabel} title={role.roleLabel}>
|
||||
{role.roleLabel}
|
||||
</span>
|
||||
<span
|
||||
className={panelStyles.cellDescription}
|
||||
title={getTextValue(role.description)}
|
||||
>
|
||||
{getTextValue(role.description) || '—'}
|
||||
</span>
|
||||
<span className={panelStyles.cellScope}>
|
||||
<strong>{scopeTypeLabel(role.scopeType)}</strong>
|
||||
</span>
|
||||
<span className={panelStyles.cellUsers}>
|
||||
<strong>{role.userCount ?? 0}</strong>
|
||||
</span>
|
||||
|
||||
{showRoleActions && (
|
||||
<div className={panelStyles.roleHeaderActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={`${panelStyles.roleActionBtn} ${panelStyles.roleActionBtnEdit}`}
|
||||
title={
|
||||
systemLocked
|
||||
? t('System-Rollen können nicht bearbeitet werden')
|
||||
: t('Rolle bearbeiten')
|
||||
}
|
||||
disabled={systemLocked}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
if (!systemLocked) setEditingRole(role);
|
||||
}}
|
||||
>
|
||||
<MdModeEdit />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`${panelStyles.roleActionBtn} ${panelStyles.roleActionBtnDelete}`}
|
||||
title={
|
||||
systemLocked
|
||||
? t('System-Rollen können nicht gelöscht werden')
|
||||
: t('Rolle löschen')
|
||||
}
|
||||
disabled={systemLocked || deletingRoleId === role.id}
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
void handleDeleteRole(role);
|
||||
}}
|
||||
>
|
||||
{deletingRoleId === role.id ? '⏳' : <IoIosTrash />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className={adminStyles.roleContent}>
|
||||
{isTemplateRole(role) && (
|
||||
<div
|
||||
className={`${adminStyles.infoBox} ${panelStyles.roleContentInfoBox}`}
|
||||
style={{
|
||||
marginBottom: '0.75rem',
|
||||
background: 'var(--warning-bg, #fffbeb)',
|
||||
borderColor: 'var(--warning-color, #d69e2e)',
|
||||
}}
|
||||
>
|
||||
<FaUserShield
|
||||
className={panelStyles.panelInfoBoxIcon}
|
||||
style={{ marginRight: '0.5rem', color: 'var(--warning-color, #d69e2e)' }}
|
||||
/>
|
||||
<span className={panelStyles.roleContentInfoBoxText}>
|
||||
{t('Dies ist eine')} <strong>{t('Template-Rolle')}</strong>.{' '}
|
||||
{t(
|
||||
'Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus. Bestehende Mandanten-Instanzen werden nicht aktualisiert.',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<AccessRulesEditor
|
||||
roleId={role.id}
|
||||
roleName={role.roleLabel}
|
||||
isTemplate={isTemplateRole(role)}
|
||||
readOnly={false}
|
||||
apiBasePath="/api/rbac"
|
||||
mandateId={mandateId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showCreateModal && (
|
||||
<Popup
|
||||
isOpen={true}
|
||||
title={t('Rolle aus Vorlage erstellen')}
|
||||
onClose={closeCreateModal}
|
||||
size="medium"
|
||||
closable={!isSubmitting}
|
||||
>
|
||||
{createFields.length === 0 || templatesLoading ? (
|
||||
<div className={adminStyles.loadingContainer}>
|
||||
<div className={adminStyles.spinner} />
|
||||
<span>{t('Lade Formular')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={panelStyles.createTemplateField}>
|
||||
<label className={panelStyles.createTemplateLabel} htmlFor="role-template-select">
|
||||
{t('Rollen-Vorlage')} *
|
||||
</label>
|
||||
<select
|
||||
id="role-template-select"
|
||||
className={panelStyles.createTemplateSelect}
|
||||
value={selectedTemplateId}
|
||||
onChange={e => handleTemplateSelect(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<option value="">{t('Vorlage wählen')}</option>
|
||||
{userRoleTemplates.map(template => {
|
||||
const alreadyInMandate = existingMandateRoleLabels.has(template.roleLabel);
|
||||
return (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.roleLabel}
|
||||
{template.isSystemRole ? ` (${t('System-Template')})` : ''}
|
||||
{template.scopeType === 'global' && !template.isSystemRole
|
||||
? ` (${t('Template')})`
|
||||
: ''}
|
||||
{alreadyInMandate ? ` — ${t('bereits vorhanden')}` : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<p className={panelStyles.createTemplateHint}>
|
||||
{userRoleTemplates.length === 0
|
||||
? t('Keine Vorlagen verfügbar.')
|
||||
: t(
|
||||
'Berechtigungen (AccessRules) der Vorlage werden in die neue Mandanten-Rolle übernommen. Bezeichnung und Beschreibung können angepasst werden.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<FormGeneratorForm
|
||||
key={selectedTemplateId || 'no-template'}
|
||||
attributes={createFields}
|
||||
data={createFormData as { roleLabel: string; description?: Record<string, string> }}
|
||||
mode="create"
|
||||
onSubmit={handleCreateRoleFromTemplate}
|
||||
onCancel={closeCreateModal}
|
||||
submitButtonText={isSubmitting ? t('Erstelle…') : t('Rolle aus Vorlage erstellen')}
|
||||
cancelButtonText={t('Abbrechen')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Popup>
|
||||
)}
|
||||
|
||||
{editingRole && (
|
||||
<Popup
|
||||
isOpen={true}
|
||||
title={`${t('Rolle bearbeiten')}: ${editingRole.roleLabel}`}
|
||||
onClose={() => !isSubmitting && setEditingRole(null)}
|
||||
size="medium"
|
||||
closable={!isSubmitting}
|
||||
>
|
||||
{editFields.length === 0 ? (
|
||||
<div className={adminStyles.loadingContainer}>
|
||||
<div className={adminStyles.spinner} />
|
||||
<span>{t('Lade Formular')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={`${adminStyles.infoBox} ${panelStyles.panelInfoBox}`}
|
||||
style={{ marginBottom: '1rem' }}
|
||||
>
|
||||
<FaUserShield className={panelStyles.panelInfoBoxIcon} style={{ marginRight: 8 }} />
|
||||
<span className={panelStyles.panelInfoBoxText}>
|
||||
{t('Geltungsbereich')}:{' '}
|
||||
<strong>
|
||||
{editingRole.mandateId ? t('Mandanten-Instanz') : t('Template (global)')}
|
||||
</strong>{' '}
|
||||
({t('kann nicht geändert werden')})
|
||||
</span>
|
||||
</div>
|
||||
<FormGeneratorForm
|
||||
attributes={editFields}
|
||||
data={editingRole}
|
||||
mode="edit"
|
||||
onSubmit={handleEditRole}
|
||||
onCancel={() => setEditingRole(null)}
|
||||
submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')}
|
||||
cancelButtonText={t('Abbrechen')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Popup>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MandateRolesPermissionsPanel;
|
||||
161
src/components/admin/MandateUsersPanel.module.css
Normal file
161
src/components/admin/MandateUsersPanel.module.css
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panelEmbedded {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.panelInDashboard {
|
||||
height: 100%;
|
||||
min-height: 280px;
|
||||
}
|
||||
|
||||
.cardInDashboard {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cardEmbedded {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg, #fff);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.headerBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
border-bottom: 1px solid var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.headerBarEmbedded {
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.headerTitle {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #0f172a);
|
||||
letter-spacing: 0.01em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btnCompactPrimary,
|
||||
.btnCompactSecondary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, opacity 0.15s;
|
||||
}
|
||||
|
||||
.btnCompactPrimary {
|
||||
background: var(--primary-color, #f25843);
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btnCompactPrimary:hover:not(:disabled) {
|
||||
background: var(--primary-dark, #d94d3a);
|
||||
}
|
||||
|
||||
.btnCompactPrimary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btnCompactSecondary {
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--text-primary, #0f172a);
|
||||
border: 1px solid var(--color-border, #e2e8f0);
|
||||
}
|
||||
|
||||
.btnCompactSecondary:hover:not(:disabled) {
|
||||
background: var(--bg-secondary, #f8fafc);
|
||||
}
|
||||
|
||||
.btnCompactSecondary:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.tableWrap {
|
||||
min-height: 200px;
|
||||
max-height: 420px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tableWrapEmbedded {
|
||||
min-height: 200px;
|
||||
height: 240px;
|
||||
max-height: 280px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tableWrap :global(.formGeneratorTable) {
|
||||
height: 100%;
|
||||
max-height: 420px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tableWrapEmbedded :global(.formGeneratorTable) {
|
||||
height: 100%;
|
||||
min-height: 180px;
|
||||
max-height: 100%;
|
||||
font-size: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tableWrapEmbedded :global(.formGeneratorTable .tableWrapper) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tableWrapEmbedded :global(.formGeneratorTable .tableContainer) {
|
||||
min-height: 120px;
|
||||
}
|
||||
392
src/components/admin/MandateUsersPanel.tsx
Normal file
392
src/components/admin/MandateUsersPanel.tsx
Normal file
|
|
@ -0,0 +1,392 @@
|
|||
/**
|
||||
* MandateUsersPanel — manage users within a single mandate (members, roles, add/remove).
|
||||
* Used in AdminMandatesPage expanded row (MandateExpandDashboard).
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import {
|
||||
useUserMandates,
|
||||
type MandateUser,
|
||||
type Role,
|
||||
type PaginationParams,
|
||||
} from '../../hooks/useUserMandates';
|
||||
import { FormGeneratorTable } from '../FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../FormGenerator/FormGeneratorForm';
|
||||
import type { ColumnConfig } from '../FormGenerator/FormGeneratorTable';
|
||||
import { FaPlus, FaSync } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import { InlineRoleMultiselect } from './InlineRoleMultiselect';
|
||||
import adminStyles from '../../pages/admin/Admin.module.css';
|
||||
import panelStyles from './MandateUsersPanel.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
||||
export interface MandateUsersPanelProps {
|
||||
mandateId: string;
|
||||
embedded?: boolean;
|
||||
/** Inside MandateExpandDashboard — no nested card border */
|
||||
embeddedInDashboard?: boolean;
|
||||
}
|
||||
|
||||
export const MandateUsersPanel: React.FC<MandateUsersPanelProps> = ({
|
||||
mandateId,
|
||||
embedded = false,
|
||||
embeddedInDashboard = false,
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { showError, showSuccess } = useToast();
|
||||
const { request } = useApiRequest();
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
pagination,
|
||||
fetchMandateUsers,
|
||||
addUserToMandate,
|
||||
removeUserFromMandate,
|
||||
updateUserRoles,
|
||||
fetchRoles,
|
||||
fetchAllUsers,
|
||||
} = useUserMandates();
|
||||
|
||||
const currentMandateIdRef = useRef(mandateId);
|
||||
currentMandateIdRef.current = mandateId;
|
||||
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [allUsers, setAllUsers] = useState<
|
||||
Array<{ id: string; username: string; email?: string; fullName?: string }>
|
||||
>([]);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
const [savingUserId, setSavingUserId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'UserMandateView')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [request]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllUsers().then(setAllUsers);
|
||||
}, [fetchAllUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mandateId) return;
|
||||
currentMandateIdRef.current = mandateId;
|
||||
fetchMandateUsers(mandateId);
|
||||
fetchRoles(mandateId).then(setRoles);
|
||||
}, [mandateId, fetchMandateUsers, fetchRoles]);
|
||||
|
||||
const refetchWithParams = useCallback(
|
||||
async (paginationParams?: PaginationParams) => {
|
||||
const id = currentMandateIdRef.current;
|
||||
if (!id) return;
|
||||
if (paginationParams && Object.keys(paginationParams).length > 0) {
|
||||
return fetchMandateUsers(paginationParams);
|
||||
}
|
||||
return fetchMandateUsers(id);
|
||||
},
|
||||
[fetchMandateUsers],
|
||||
);
|
||||
|
||||
const availableUsers = useMemo(() => {
|
||||
const existingUserIds = new Set(users.map((u) => u.userId));
|
||||
return allUsers.filter((u) => !existingUserIds.has(u.id));
|
||||
}, [allUsers, users]);
|
||||
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
roles
|
||||
.filter((r) => !r.featureInstanceId && !r.featureCode)
|
||||
.map((r) => ({
|
||||
value: r.id,
|
||||
label: r.roleLabel || r.id,
|
||||
})),
|
||||
[roles],
|
||||
);
|
||||
|
||||
const handleRoleChange = useCallback(
|
||||
async (userId: string, roleIds: string[]) => {
|
||||
if (!mandateId) return;
|
||||
setSavingUserId(userId);
|
||||
try {
|
||||
const result = await updateUserRoles(mandateId, userId, roleIds);
|
||||
if (!result.success) {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Rollen'));
|
||||
await fetchMandateUsers(mandateId);
|
||||
}
|
||||
} finally {
|
||||
setSavingUserId(null);
|
||||
}
|
||||
},
|
||||
[mandateId, updateUserRoles, showError, t, fetchMandateUsers],
|
||||
);
|
||||
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => {
|
||||
const cols: ColumnConfig[] = [
|
||||
{
|
||||
key: 'username',
|
||||
label: t('Benutzername'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: embedded ? 100 : 140,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: t('E-Mail'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: embedded ? 130 : 180,
|
||||
},
|
||||
];
|
||||
if (!embedded) {
|
||||
cols.push({
|
||||
key: 'fullName',
|
||||
label: t('Vollständiger Name'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 160,
|
||||
});
|
||||
}
|
||||
cols.push(
|
||||
{
|
||||
key: 'roleIds',
|
||||
label: t('Rollen'),
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
searchable: false,
|
||||
width: embedded ? 150 : 220,
|
||||
formatter: (_value: unknown, row: MandateUser) => (
|
||||
<InlineRoleMultiselect
|
||||
value={row.roleIds ?? []}
|
||||
options={roleOptions}
|
||||
onChange={(ids) => handleRoleChange(row.userId, ids)}
|
||||
loading={savingUserId === row.userId}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: t('Aktiv'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: false,
|
||||
width: embedded ? 52 : 72,
|
||||
},
|
||||
);
|
||||
return cols;
|
||||
}, [t, roleOptions, handleRoleChange, savingUserId, embedded]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
const userOptions = useMemo(
|
||||
() =>
|
||||
availableUsers.map((u) => ({
|
||||
value: u.id,
|
||||
label: `${u.username} ${u.email ? `(${u.email})` : ''}`,
|
||||
})),
|
||||
[availableUsers],
|
||||
);
|
||||
|
||||
const addUserFields: AttributeDefinition[] = useMemo(() => {
|
||||
const userIdAttr = backendAttributes.find((a) => a.name === 'userId');
|
||||
const roleIdsAttr = backendAttributes.find((a) => a.name === 'roleIds');
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'targetUserId',
|
||||
label: userIdAttr?.label || t('Benutzer'),
|
||||
type: 'enum' as AttributeDefinition['type'],
|
||||
required: true,
|
||||
options: userOptions,
|
||||
},
|
||||
{
|
||||
name: 'roleIds',
|
||||
label: roleIdsAttr?.label || t('Rollen'),
|
||||
type: 'multiselect' as AttributeDefinition['type'],
|
||||
required: true,
|
||||
options: roleOptions,
|
||||
},
|
||||
];
|
||||
}, [userOptions, roleOptions, backendAttributes, t]);
|
||||
|
||||
const handleAddUser = async (data: { targetUserId: string; roleIds: string[] }) => {
|
||||
if (!mandateId) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await addUserToMandate(mandateId, data);
|
||||
if (result.success) {
|
||||
setShowAddModal(false);
|
||||
showSuccess(t('Hinzugefügt'), t('Benutzer wurde dem Mandanten zugewiesen.'));
|
||||
fetchMandateUsers(mandateId);
|
||||
} else {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Hinzufügen des Benutzers'));
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveUser = async (user: MandateUser) => {
|
||||
if (!mandateId) return;
|
||||
const result = await removeUserFromMandate(mandateId, user.userId);
|
||||
if (!result.success) {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Entfernen des Benutzers'));
|
||||
}
|
||||
};
|
||||
|
||||
const refreshBtn = embedded ? (
|
||||
<button
|
||||
type="button"
|
||||
className={panelStyles.btnCompactSecondary}
|
||||
onClick={() => fetchMandateUsers(mandateId)}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={adminStyles.secondaryButton}
|
||||
onClick={() => fetchMandateUsers(mandateId)}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||
</button>
|
||||
);
|
||||
|
||||
const addBtn = embedded ? (
|
||||
<button
|
||||
type="button"
|
||||
className={panelStyles.btnCompactPrimary}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
disabled={availableUsers.length === 0}
|
||||
title={
|
||||
availableUsers.length === 0
|
||||
? t('Alle Benutzer sind bereits diesem Mandanten zugewiesen')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FaPlus /> {t('Benutzer hinzufügen')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={adminStyles.primaryButton}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
disabled={availableUsers.length === 0}
|
||||
title={
|
||||
availableUsers.length === 0
|
||||
? t('Alle Benutzer sind bereits diesem Mandanten zugewiesen')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<FaPlus /> {t('Benutzer hinzufügen')}
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${panelStyles.panel} ${embedded ? panelStyles.panelEmbedded : ''} ${embeddedInDashboard ? panelStyles.panelInDashboard : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`${panelStyles.card} ${embedded && !embeddedInDashboard ? panelStyles.cardEmbedded : ''} ${embeddedInDashboard ? panelStyles.cardInDashboard : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`${panelStyles.headerBar} ${embedded ? panelStyles.headerBarEmbedded : ''}`}
|
||||
>
|
||||
<h3 className={panelStyles.headerTitle}>{t('Mandanten-Benutzer')}</h3>
|
||||
<div className={panelStyles.headerActions}>
|
||||
{refreshBtn}
|
||||
{addBtn}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${panelStyles.tableWrap} ${embedded ? panelStyles.tableWrapEmbedded : ''}`}
|
||||
>
|
||||
<FormGeneratorTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
apiEndpoint={mandateId ? `/api/mandates/${mandateId}/users` : undefined}
|
||||
loading={loading}
|
||||
pagination
|
||||
pageSize={embedded ? 10 : 25}
|
||||
searchable
|
||||
filterable
|
||||
sortable
|
||||
selectable={false}
|
||||
compact
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'delete' as const,
|
||||
title: t('Vom Mandat entfernen'),
|
||||
},
|
||||
]}
|
||||
onDelete={handleRemoveUser}
|
||||
hookData={{
|
||||
refetch: refetchWithParams,
|
||||
pagination,
|
||||
handleDelete: async (userMandateId: string) => {
|
||||
const user = users.find((u) => u.id === userMandateId);
|
||||
if (user) {
|
||||
const result = await removeUserFromMandate(mandateId, user.userId);
|
||||
return result.success;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}}
|
||||
emptyMessage={t('Keine Mitglieder gefunden')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showAddModal && (
|
||||
<div className={adminStyles.modalOverlay}>
|
||||
<div className={adminStyles.modal}>
|
||||
<div className={adminStyles.modalHeader}>
|
||||
<h2 className={adminStyles.modalTitle}>{t('Benutzer zum Mandanten hinzufügen')}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className={adminStyles.modalClose}
|
||||
onClick={() => setShowAddModal(false)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={adminStyles.modalContent}>
|
||||
{availableUsers.length === 0 ? (
|
||||
<p>{t('Alle Benutzer sind bereits diesem Mandanten zugewiesen.')}</p>
|
||||
) : roleOptions.length === 0 ? (
|
||||
<div className={adminStyles.loadingContainer}>
|
||||
<div className={adminStyles.spinner} />
|
||||
<span>{t('Lade Rollen')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={addUserFields}
|
||||
mode="create"
|
||||
onSubmit={handleAddUser}
|
||||
onCancel={() => setShowAddModal(false)}
|
||||
submitButtonText={isSubmitting ? t('Hinzufügen…') : t('Hinzufügen')}
|
||||
cancelButtonText={t('Abbrechen')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MandateUsersPanel;
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
FaHome, FaCog, FaBriefcase, FaPlay, FaBuilding, FaUsers, FaUserTag,
|
||||
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
|
||||
FaCubes, FaEnvelopeOpenText, FaUsersCog, FaCube, FaShieldAlt,
|
||||
FaLightbulb, FaRegFileAlt, FaLink, FaComments,
|
||||
FaListAlt, FaChartLine, FaChartBar, FaFileAlt, FaUserShield, FaDatabase,
|
||||
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
|
||||
|
|
@ -64,11 +64,8 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'page.admin.users': <FaUsers />,
|
||||
'page.admin.invitations': <FaEnvelopeOpenText />,
|
||||
'page.admin.mandates': <FaBuilding />,
|
||||
'page.admin.roles': <FaKey />,
|
||||
'page.admin.userRoleTemplates': <FaUserTag />,
|
||||
'page.admin.role-permissions': <FaShieldAlt />,
|
||||
'page.admin.mandateRolePermissions': <FaShieldAlt />,
|
||||
'page.admin.user-mandates': <FaUserTag />,
|
||||
'page.admin.userMandates': <FaUserTag />,
|
||||
'page.admin.feature-roles': <FaCube />,
|
||||
'page.admin.featureRoles': <FaCube />,
|
||||
'page.admin.feature-instances': <FaCubes />,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export interface Role {
|
|||
featureCode?: string;
|
||||
isSystemRole?: boolean;
|
||||
isTemplate?: boolean;
|
||||
scopeType?: 'system' | 'global' | 'mandate';
|
||||
userCount?: number;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
|
@ -276,12 +278,83 @@ export function useMandateRoles() {
|
|||
return roles.filter(r => r.isTemplate === true);
|
||||
}, [roles]);
|
||||
|
||||
/** Global + system user role templates (no mandateId).
|
||||
* Used by AdminUserRoleTemplatesPage — updates the shared `roles` state.
|
||||
* NOTE: passes `undefined` as first arg so fetchRoles skips both branches and
|
||||
* does NOT inherit currentMandateIdRef, ensuring no mandate header is sent. */
|
||||
const fetchUserRoleTemplates = useCallback(
|
||||
async (paginationParams?: PaginationParams): Promise<Role[]> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return fetchRoles(undefined as any, { ...paginationParams });
|
||||
},
|
||||
[fetchRoles],
|
||||
);
|
||||
|
||||
/** Fetch global/system templates without touching the shared `roles` state.
|
||||
* Safe to call from components that already manage their own role list
|
||||
* (e.g. MandateRolesPermissionsPanel) so the displayed roles aren't clobbered. */
|
||||
const fetchTemplatesOnly = useCallback(async (): Promise<Role[]> => {
|
||||
try {
|
||||
const response = await api.get('/api/rbac/roles', {
|
||||
params: { includeTemplates: 'false', scopeFilter: 'global' },
|
||||
});
|
||||
if (response.data?.items && Array.isArray(response.data.items)) {
|
||||
return response.data.items as Role[];
|
||||
}
|
||||
if (Array.isArray(response.data)) {
|
||||
return response.data as Role[];
|
||||
}
|
||||
return [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Create a mandate role from a user role template (copies AccessRules).
|
||||
*/
|
||||
const createRoleFromTemplate = useCallback(
|
||||
async (
|
||||
templateRoleId: string,
|
||||
targetMandateId: string,
|
||||
data?: { roleLabel?: string; description?: Record<string, string> },
|
||||
): Promise<{ success: boolean; data?: Role; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const headers: Record<string, string> = { 'X-Mandate-Id': targetMandateId };
|
||||
const response = await api.post(
|
||||
'/api/rbac/roles/from-template',
|
||||
{
|
||||
templateRoleId,
|
||||
mandateId: targetMandateId,
|
||||
roleLabel: data?.roleLabel,
|
||||
description: data?.description,
|
||||
},
|
||||
{ headers },
|
||||
);
|
||||
return { success: true, data: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage =
|
||||
err.response?.data?.detail || err.message || 'Failed to create role from template';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
fetchRoles,
|
||||
fetchUserRoleTemplates,
|
||||
fetchTemplatesOnly,
|
||||
createRoleFromTemplate,
|
||||
getRole,
|
||||
createRole,
|
||||
updateRole,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -413,9 +413,6 @@ export const AccessManagementHub: React.FC = () => {
|
|||
<Link to="/admin/mandates" className={hubStyles.mandatesLink}>
|
||||
<FaBuilding /> {t('Mandanten verwalten')}
|
||||
</Link>
|
||||
<Link to="/admin/user-mandates" className={hubStyles.mandatesLink}>
|
||||
<FaUsers /> {t('Mandant-Benutzer')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{viewMode === 'hierarchy' ? (
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
.catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates, request]);
|
||||
|
||||
// Load invitations and roles when mandate changes (same roles as AdminUserMandatesPage: user, viewer, admin)
|
||||
// Load invitations and roles when mandate changes (same roles as MandateUsersPanel: user, viewer, admin)
|
||||
useEffect(() => {
|
||||
if (selectedMandateId) {
|
||||
fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed });
|
||||
|
|
@ -122,7 +122,7 @@ export const AdminInvitationsPage: React.FC = () => {
|
|||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
// Form attributes - same role options as AdminUserMandatesPage (user, viewer, admin)
|
||||
// Form attributes - same role options as MandateUsersPanel (user, viewer, admin)
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'token', 'sysCreatedBy', 'sysCreatedAt', 'sysUpdatedAt', 'sysUpdatedBy', 'expiresAt', 'currentUses', 'inviteUrl', 'featureInstanceId'];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,603 +0,0 @@
|
|||
/**
|
||||
* AdminMandateRolePermissionsPage
|
||||
*
|
||||
* Admin page for managing access rules (permissions) for mandate-level roles.
|
||||
* Similar to TrusteeInstanceRolesView but for mandate/global roles.
|
||||
*
|
||||
* Shows:
|
||||
* - System roles (admin, user, viewer) - read-only permissions
|
||||
* - Global roles (mandateId=null) - editable permissions
|
||||
* - Mandate-specific roles (mandateId=xyz) - editable permissions
|
||||
*
|
||||
* Each role can be expanded to show/edit its AccessRules via AccessRulesEditor.
|
||||
*
|
||||
* Includes a "Cleanup Duplicates" tool to find and remove duplicate AccessRules.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useMandateRoles, type Role } from '../../hooks/useMandateRoles';
|
||||
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
||||
import { AccessRulesEditor } from '../../components/AccessRules';
|
||||
import api from '../../api';
|
||||
import {
|
||||
FaUserShield,
|
||||
FaShieldAlt,
|
||||
FaSync,
|
||||
FaChevronDown,
|
||||
FaChevronRight,
|
||||
FaGlobe,
|
||||
FaBuilding,
|
||||
FaFilter,
|
||||
FaBroom,
|
||||
FaTimes,
|
||||
FaExclamationTriangle,
|
||||
FaCheckCircle
|
||||
} from 'react-icons/fa';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||
|
||||
// Types for cleanup result
|
||||
interface DuplicateGroup {
|
||||
roleId: string;
|
||||
context: string;
|
||||
item: string;
|
||||
totalCount: number;
|
||||
keepId: string;
|
||||
deleteCount: number;
|
||||
deleteIds: string[];
|
||||
}
|
||||
|
||||
interface CleanupResult {
|
||||
totalRules: number;
|
||||
uniqueSignatures: number;
|
||||
duplicateGroups: number;
|
||||
duplicateRulesToDelete: number;
|
||||
deletedCount: number;
|
||||
details: DuplicateGroup[];
|
||||
}
|
||||
|
||||
interface TemplateFixDetail {
|
||||
userMandateRoleId: string;
|
||||
userMandateId: string;
|
||||
mandateId: string;
|
||||
templateRoleId: string;
|
||||
templateRoleLabel: string;
|
||||
instanceRoleId?: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
interface TemplateFixResult {
|
||||
totalUserMandateRoles: number;
|
||||
invalidAssignments: number;
|
||||
fixedCount: number;
|
||||
details: TemplateFixDetail[];
|
||||
}
|
||||
|
||||
export const AdminMandateRolePermissionsPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const {
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
fetchRoles,
|
||||
} = useMandateRoles();
|
||||
|
||||
const { fetchMandates } = useUserMandates();
|
||||
|
||||
// State
|
||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
|
||||
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
|
||||
|
||||
// Cleanup state
|
||||
const [showCleanupModal, setShowCleanupModal] = useState(false);
|
||||
const [cleanupLoading, setCleanupLoading] = useState(false);
|
||||
const [cleanupResult, setCleanupResult] = useState<CleanupResult | null>(null);
|
||||
const [templateFixResult, setTemplateFixResult] = useState<TemplateFixResult | null>(null);
|
||||
const [cleanupError, setCleanupError] = useState<string | null>(null);
|
||||
const [cleanupPhase, setCleanupPhase] = useState<'idle' | 'preview' | 'done'>('idle');
|
||||
|
||||
// Load mandates on mount
|
||||
useEffect(() => {
|
||||
const loadMandates = async () => {
|
||||
const data = await fetchMandates();
|
||||
setMandates(data);
|
||||
if (data.length > 0 && !selectedMandateId) {
|
||||
setSelectedMandateId(data[0].id);
|
||||
}
|
||||
};
|
||||
loadMandates();
|
||||
}, [fetchMandates]);
|
||||
|
||||
// Load roles when mandate or scopeFilter changes
|
||||
useEffect(() => {
|
||||
if (selectedMandateId) {
|
||||
fetchRoles(selectedMandateId, { scopeFilter });
|
||||
}
|
||||
}, [selectedMandateId, scopeFilter, fetchRoles]);
|
||||
|
||||
// Refetch roles
|
||||
const handleRefresh = useCallback(async () => {
|
||||
if (selectedMandateId) {
|
||||
await fetchRoles(selectedMandateId, { scopeFilter });
|
||||
}
|
||||
}, [selectedMandateId, scopeFilter, fetchRoles]);
|
||||
|
||||
const getTextValue = (value: string | Record<string, string> | undefined): string => {
|
||||
if (!value) return '';
|
||||
if (typeof value === 'string') return value;
|
||||
return Object.values(value).find(v => !!v) || '';
|
||||
};
|
||||
|
||||
// Toggle role expansion
|
||||
const toggleRole = (roleId: string) => {
|
||||
setExpandedRoleId(prev => prev === roleId ? null : roleId);
|
||||
};
|
||||
|
||||
// Check if a role is a template (not bound to a specific mandate)
|
||||
const _isTemplateRole = (role: Role): boolean => {
|
||||
return !!role.isSystemRole || !role.mandateId;
|
||||
};
|
||||
|
||||
// Get scope badge
|
||||
const getScopeBadge = (role: Role) => {
|
||||
if (role.isSystemRole) {
|
||||
return (
|
||||
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
|
||||
<FaUserShield style={{ marginRight: 4 }} /> {t('System-Template')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (!role.mandateId) {
|
||||
return (
|
||||
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
|
||||
<FaGlobe style={{ marginRight: 4 }} /> {t('Template')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={styles.badge} style={{ background: 'var(--success-color, #38a169)', color: 'white' }}>
|
||||
<FaBuilding style={{ marginRight: 4 }} /> {t('Mandant')}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Cleanup functions ---
|
||||
const _openCleanupModal = useCallback(async () => {
|
||||
setShowCleanupModal(true);
|
||||
setCleanupError(null);
|
||||
setCleanupResult(null);
|
||||
setTemplateFixResult(null);
|
||||
setCleanupPhase('idle');
|
||||
setCleanupLoading(true);
|
||||
try {
|
||||
const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=true');
|
||||
const data = response.data;
|
||||
setCleanupResult(data.duplicateRules || data);
|
||||
setTemplateFixResult(data.templateRoleAssignments || null);
|
||||
setCleanupPhase('preview');
|
||||
} catch (err: any) {
|
||||
setCleanupError(err?.response?.data?.detail || err?.message || t('Fehler beim Laden der Duplikate'));
|
||||
} finally {
|
||||
setCleanupLoading(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const _executeCleanup = useCallback(async () => {
|
||||
setCleanupLoading(true);
|
||||
setCleanupError(null);
|
||||
try {
|
||||
const response = await api.post('/api/rbac/cleanup/duplicate-rules?dryRun=false');
|
||||
const data = response.data;
|
||||
setCleanupResult(data.duplicateRules || data);
|
||||
setTemplateFixResult(data.templateRoleAssignments || null);
|
||||
setCleanupPhase('done');
|
||||
// Refresh roles after cleanup
|
||||
if (selectedMandateId) {
|
||||
fetchRoles(selectedMandateId, { scopeFilter });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setCleanupError(err?.response?.data?.detail || err?.message || t('Fehler beim Bereinigen'));
|
||||
} finally {
|
||||
setCleanupLoading(false);
|
||||
}
|
||||
}, [selectedMandateId, scopeFilter, fetchRoles, t]);
|
||||
|
||||
const _closeCleanupModal = useCallback(() => {
|
||||
setShowCleanupModal(false);
|
||||
setCleanupResult(null);
|
||||
setCleanupError(null);
|
||||
setCleanupPhase('idle');
|
||||
}, []);
|
||||
|
||||
// Filter options for scope
|
||||
const scopeOptions = useMemo(() => [
|
||||
{ value: 'mandate', label: t('Mandanten-Rollen') },
|
||||
{ value: 'all', label: t('Alle inkl. Templates') },
|
||||
{ value: 'global', label: t('Nur Templates') },
|
||||
], [t]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>
|
||||
{t('Fehler beim Laden')}: {error}
|
||||
</p>
|
||||
<button className={styles.secondaryButton} onClick={handleRefresh}>
|
||||
<FaSync /> {t('Erneut versuchen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
{/* Header */}
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>
|
||||
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
|
||||
{t('Rollen-Berechtigungen')}
|
||||
</h1>
|
||||
<p className={styles.pageSubtitle}>
|
||||
{t('Verwalten Sie die Zugriffsrechte für Mandanten- und globale Rollen')}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={_openCleanupModal}
|
||||
disabled={loading}
|
||||
title={t('Doppelte Regeln finden und bereinigen')}
|
||||
>
|
||||
<FaBroom /> {t('Duplikate bereinigen')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className={styles.filterBar}>
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>{t('Mandant')}</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={selectedMandateId}
|
||||
onChange={(e) => setSelectedMandateId(e.target.value)}
|
||||
>
|
||||
{mandates.map(mandate => (
|
||||
<option key={mandate.id} value={mandate.id}>
|
||||
{mandateDisplayLabel(mandate)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>
|
||||
<FaFilter style={{ marginRight: 4 }} /> {t('Bereich')}:
|
||||
</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={scopeFilter}
|
||||
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
|
||||
>
|
||||
{scopeOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className={styles.infoBox}>
|
||||
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
|
||||
<span>
|
||||
{t('Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.')}{' '}
|
||||
<strong>{t('Template-Rollen')}</strong>{' '}
|
||||
{t('sind schreibgeschützt – Änderungen an Templates wirken sich nur auf neu erstellte Mandanten aus.')}{' '}
|
||||
<strong>{t('Mandanten-Rollen')}</strong> {t('sind direkt bearbeitbar.')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>{t('Lade Rollen')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!loading && roles.length === 0 && (
|
||||
<div className={styles.emptyState}>
|
||||
<FaUserShield className={styles.emptyIcon} />
|
||||
<p>{t('Keine Rollen gefunden')}</p>
|
||||
<p className={styles.emptyHint}>
|
||||
{scopeFilter === 'mandate'
|
||||
? t('Es gibt noch keine Mandanten-Rollen. System-Rollen werden bei der Mandant-Erstellung automatisch kopiert.')
|
||||
: scopeFilter === 'global'
|
||||
? t('Es gibt noch keine Rollentemplates')
|
||||
: t('Es gibt noch keine Rollen')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Roles List */}
|
||||
{!loading && roles.length > 0 && (
|
||||
<div className={styles.rolesList}>
|
||||
{roles.map(role => (
|
||||
<div key={role.id} className={styles.roleCard}>
|
||||
{/* Role Header - Clickable to expand */}
|
||||
<div
|
||||
className={styles.roleHeader}
|
||||
onClick={() => toggleRole(role.id)}
|
||||
>
|
||||
<div className={styles.roleInfo}>
|
||||
<span className={styles.expandIcon}>
|
||||
{expandedRoleId === role.id ? <FaChevronDown /> : <FaChevronRight />}
|
||||
</span>
|
||||
<span className={styles.roleLabel}>{role.roleLabel}</span>
|
||||
<span className={styles.roleDescription}>
|
||||
{getTextValue(role.description)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.roleBadges}>
|
||||
{getScopeBadge(role)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Content - AccessRulesEditor */}
|
||||
{expandedRoleId === role.id && (
|
||||
<div className={styles.roleContent}>
|
||||
{_isTemplateRole(role) && (
|
||||
<div className={styles.infoBox} style={{ marginBottom: '0.75rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
||||
<FaUserShield style={{ marginRight: '0.5rem', color: 'var(--warning-color, #d69e2e)' }} />
|
||||
<span>
|
||||
{t('Dies ist eine')} <strong>{t('Template-Rolle')}</strong>.{' '}
|
||||
{t('Änderungen an den Berechtigungen wirken sich nur auf neu erstellte Mandanten aus. Bestehende Mandanten-Instanzen werden nicht aktualisiert.')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<AccessRulesEditor
|
||||
roleId={role.id}
|
||||
roleName={role.roleLabel}
|
||||
isTemplate={_isTemplateRole(role)}
|
||||
readOnly={false}
|
||||
apiBasePath="/api/rbac"
|
||||
mandateId={selectedMandateId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cleanup Duplicates Modal */}
|
||||
{showCleanupModal && (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal} style={{ maxWidth: '750px' }}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h3 className={styles.modalTitle}>
|
||||
<FaBroom style={{ marginRight: '0.5rem' }} />
|
||||
{t('Doppelte Regeln bereinigen')}
|
||||
</h3>
|
||||
<button className={styles.modalClose} onClick={_closeCleanupModal}>
|
||||
<FaTimes />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={styles.modalContent}>
|
||||
{/* Loading */}
|
||||
{cleanupLoading && (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>{cleanupPhase === 'idle' ? t('Analysiere Duplikate') : t('Bereinige Duplikate')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{cleanupError && (
|
||||
<div style={{ padding: '1rem', background: '#fed7d7', borderRadius: '6px', color: '#c53030', marginBottom: '1rem' }}>
|
||||
<FaExclamationTriangle style={{ marginRight: '0.5rem' }} />
|
||||
{cleanupError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{cleanupResult && !cleanupLoading && (
|
||||
<>
|
||||
{/* Summary Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem', marginBottom: '1.25rem' }}>
|
||||
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{cleanupResult.totalRules}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Regeln total')}</div>
|
||||
</div>
|
||||
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{cleanupResult.uniqueSignatures}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Eindeutige Regeln')}</div>
|
||||
</div>
|
||||
<div style={{ padding: '0.75rem', background: cleanupResult.duplicateGroups > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateGroups > 0 ? '#fc8181' : '#9ae6b4'}` }}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: cleanupResult.duplicateGroups > 0 ? '#c53030' : '#2f855a' }}>{cleanupResult.duplicateGroups}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Duplikat-Gruppen')}</div>
|
||||
</div>
|
||||
<div style={{ padding: '0.75rem', background: cleanupResult.duplicateRulesToDelete > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${cleanupResult.duplicateRulesToDelete > 0 ? '#fc8181' : '#9ae6b4'}` }}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: cleanupResult.duplicateRulesToDelete > 0 ? '#c53030' : '#2f855a' }}>
|
||||
{cleanupPhase === 'done' ? cleanupResult.deletedCount : cleanupResult.duplicateRulesToDelete}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
|
||||
{cleanupPhase === 'done' ? t('Gelöscht') : t('Zu löschen')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Message */}
|
||||
{cleanupPhase === 'done' && (
|
||||
<div style={{ padding: '0.75rem 1rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
|
||||
<FaCheckCircle />
|
||||
<span><strong>{cleanupResult.deletedCount}</strong> {t('Doppelte Regeln wurden erfolgreich entfernt')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cleanupPhase === 'preview' && cleanupResult.duplicateGroups === 0 && (
|
||||
<div style={{ padding: '0.75rem 1rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', marginBottom: '1rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
|
||||
<FaCheckCircle />
|
||||
<span>{t('Keine Duplikate gefunden, alles sauber')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Table */}
|
||||
{cleanupResult.details && cleanupResult.details.length > 0 && (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<h4 style={{ fontSize: '0.875rem', fontWeight: 600, marginBottom: '0.5rem', color: 'var(--text-secondary)' }}>
|
||||
{t('Duplikat-Details')}{' '}
|
||||
{cleanupResult.details.length < cleanupResult.duplicateGroups &&
|
||||
`(${cleanupResult.details.length} ${t('von')} ${cleanupResult.duplicateGroups})`}
|
||||
</h4>
|
||||
<div style={{ maxHeight: '300px', overflowY: 'auto', border: '1px solid var(--border-color)', borderRadius: '6px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--bg-secondary)', position: 'sticky', top: 0 }}>
|
||||
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Kontext')}</th>
|
||||
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Item')}</th>
|
||||
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Total')}</th>
|
||||
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)', color: '#c53030' }}>{t('Duplikate')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cleanupResult.details.map((group, idx) => (
|
||||
<tr key={idx} style={{ borderBottom: '1px solid var(--border-color)' }}>
|
||||
<td style={{ padding: '0.375rem 0.75rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
|
||||
{group.context}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '0.375rem 0.75rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
|
||||
{group.item}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center' }}>{group.totalCount}</td>
|
||||
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center', color: '#c53030', fontWeight: 600 }}>{group.deleteCount}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template Role Assignments Section */}
|
||||
{templateFixResult && templateFixResult.invalidAssignments > 0 && (
|
||||
<div style={{ marginTop: '1.25rem', borderTop: '1px solid var(--border-color)', paddingTop: '1rem' }}>
|
||||
<h4 style={{ fontSize: '0.9375rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text-primary)' }}>
|
||||
{t('Template-Rollen-Zuweisungen')}
|
||||
</h4>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: '0.75rem', marginBottom: '1rem' }}>
|
||||
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: 'var(--text-primary)' }}>{templateFixResult.totalUserMandateRoles}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Rollenzuweisungen total')}</div>
|
||||
</div>
|
||||
<div style={{ padding: '0.75rem', background: templateFixResult.invalidAssignments > 0 ? '#fff5f5' : '#f0fff4', borderRadius: '8px', textAlign: 'center', border: `1px solid ${templateFixResult.invalidAssignments > 0 ? '#fc8181' : '#9ae6b4'}` }}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: templateFixResult.invalidAssignments > 0 ? '#c53030' : '#2f855a' }}>{templateFixResult.invalidAssignments}</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>{t('Template statt Instanz')}</div>
|
||||
</div>
|
||||
<div style={{ padding: '0.75rem', background: 'var(--bg-secondary)', borderRadius: '8px', textAlign: 'center', border: '1px solid var(--border-color)' }}>
|
||||
<div style={{ fontSize: '1.5rem', fontWeight: 700, color: templateFixResult.fixedCount > 0 ? '#2f855a' : 'var(--text-primary)' }}>
|
||||
{cleanupPhase === 'done' ? templateFixResult.fixedCount : templateFixResult.invalidAssignments}
|
||||
</div>
|
||||
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
|
||||
{cleanupPhase === 'done' ? t('Repariert') : t('Zu reparieren')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templateFixResult.details && templateFixResult.details.length > 0 && (
|
||||
<div style={{ maxHeight: '200px', overflowY: 'auto', border: '1px solid var(--border-color)', borderRadius: '6px' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.8125rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--bg-secondary)', position: 'sticky', top: 0 }}>
|
||||
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Rolle')}</th>
|
||||
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'left', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Mandant')}</th>
|
||||
<th style={{ padding: '0.5rem 0.75rem', textAlign: 'center', fontWeight: 600, borderBottom: '1px solid var(--border-color)' }}>{t('Aktion')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{templateFixResult.details.map((detail, idx) => (
|
||||
<tr key={idx} style={{ borderBottom: '1px solid var(--border-color)' }}>
|
||||
<td style={{ padding: '0.375rem 0.75rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
|
||||
{detail.templateRoleLabel}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '0.375rem 0.75rem' }}>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.75rem', background: 'var(--bg-tertiary)', padding: '0.125rem 0.375rem', borderRadius: '3px' }}>
|
||||
{detail.mandateId.substring(0, 8)}...
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ padding: '0.375rem 0.75rem', textAlign: 'center' }}>
|
||||
<span style={{
|
||||
fontSize: '0.75rem',
|
||||
padding: '0.125rem 0.5rem',
|
||||
borderRadius: '10px',
|
||||
background: detail.action.includes('replace') ? '#ebf8ff' : detail.action.includes('delete') ? '#fff5f5' : '#f7fafc',
|
||||
color: detail.action.includes('replace') ? '#2b6cb0' : detail.action.includes('delete') ? '#c53030' : '#718096',
|
||||
}}>
|
||||
{detail.action}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{templateFixResult && templateFixResult.invalidAssignments === 0 && (
|
||||
<div style={{ marginTop: '1rem', padding: '0.5rem 0.75rem', background: '#f0fff4', borderRadius: '6px', color: '#2f855a', fontSize: '0.875rem', display: 'flex', alignItems: 'center', gap: '0.5rem', border: '1px solid #9ae6b4' }}>
|
||||
<FaCheckCircle />
|
||||
<span>{t('Keine fehlerhaften Templaterollenzuweisungen')}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.modalFooter}>
|
||||
<button className={styles.secondaryButton} onClick={_closeCleanupModal}>
|
||||
{cleanupPhase === 'done' ? t('Schließen') : t('Abbrechen')}
|
||||
</button>
|
||||
{cleanupPhase === 'preview' && cleanupResult && (cleanupResult.duplicateRulesToDelete > 0 || (templateFixResult && templateFixResult.invalidAssignments > 0)) && (
|
||||
<button
|
||||
className={styles.dangerButton}
|
||||
onClick={_executeCleanup}
|
||||
disabled={cleanupLoading}
|
||||
>
|
||||
<FaBroom /> {t('Bereinigung ausführen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminMandateRolePermissionsPage;
|
||||
|
|
@ -1,486 +0,0 @@
|
|||
/**
|
||||
* AdminMandateRolesPage
|
||||
*
|
||||
* Admin page for managing ALL ROLES (system + global + mandate-specific).
|
||||
* Consolidated view replacing separate System-Roles page.
|
||||
*
|
||||
* Shows:
|
||||
* - System roles (admin, user, viewer) - read-only, cannot be deleted
|
||||
* - Global roles (mandateId=null) - CRUD available
|
||||
* - Mandate-specific roles (mandateId=xyz) - CRUD available
|
||||
* - Feature-template roles are managed in AdminFeatureRolesPage
|
||||
*
|
||||
* ALL filtering, sorting, and pagination is handled by the backend.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMandateRoles, type Role, type RoleCreate, type RoleUpdate, type PaginationParams } from '../../hooks/useMandateRoles';
|
||||
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaShieldAlt, FaCube } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||
|
||||
export const AdminMandateRolesPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguage();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { request } = useApiRequest();
|
||||
const { showError, showWarning } = useToast();
|
||||
const {
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
fetchRoles,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
} = useMandateRoles();
|
||||
|
||||
const { fetchMandates } = useUserMandates();
|
||||
|
||||
// State
|
||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('mandate');
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
// Store current filter state for refetch
|
||||
const currentScopeFilterRef = useRef(scopeFilter);
|
||||
currentScopeFilterRef.current = scopeFilter;
|
||||
|
||||
// Load mandates and attributes on mount
|
||||
useEffect(() => {
|
||||
const loadMandates = async () => {
|
||||
const data = await fetchMandates();
|
||||
setMandates(data);
|
||||
if (data.length > 0 && !selectedMandateId) {
|
||||
setSelectedMandateId(data[0].id);
|
||||
}
|
||||
};
|
||||
loadMandates();
|
||||
fetchAttributes(request, 'RoleView')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates, request]);
|
||||
|
||||
// Load roles when mandate or scopeFilter changes
|
||||
useEffect(() => {
|
||||
if (selectedMandateId) {
|
||||
fetchRoles(selectedMandateId, { scopeFilter });
|
||||
}
|
||||
}, [selectedMandateId, scopeFilter, fetchRoles]);
|
||||
|
||||
// Refetch wrapper that accepts pagination params from FormGeneratorTable
|
||||
// and includes the current mandateId and scopeFilter
|
||||
const refetchWithParams = useCallback(async (paginationParams?: PaginationParams) => {
|
||||
if (!selectedMandateId) return;
|
||||
// Merge pagination params with current filter state
|
||||
return fetchRoles(selectedMandateId, {
|
||||
...paginationParams,
|
||||
scopeFilter: currentScopeFilterRef.current
|
||||
});
|
||||
}, [selectedMandateId, fetchRoles]);
|
||||
|
||||
const getDescriptionText = (desc: any) => {
|
||||
if (!desc) return '-';
|
||||
if (typeof desc === 'string') return desc;
|
||||
if (typeof desc === 'object') {
|
||||
return desc[currentLanguage] || desc['xx'] || Object.values(desc).find((v: any) => typeof v === 'string' && v.trim()) || '-';
|
||||
}
|
||||
return String(desc);
|
||||
};
|
||||
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{ key: 'roleLabel', sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{
|
||||
key: 'description',
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
width: 250,
|
||||
formatter: (value: string) => getDescriptionText(value),
|
||||
},
|
||||
{ key: 'scopeType', sortable: true, filterable: true, width: 160 },
|
||||
{ key: 'userCount', sortable: true, filterable: true, width: 100 },
|
||||
], []);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
// Form attributes from backend - for create form
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
|
||||
|
||||
const fields = backendAttributes
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({ ...attr })) as AttributeDefinition[];
|
||||
|
||||
// Add scope field for mandate/global selection (not a model attribute)
|
||||
if (fields.length > 0) {
|
||||
fields.push({
|
||||
name: 'scope',
|
||||
label: t('Geltungsbereich'),
|
||||
type: 'enum' as any,
|
||||
required: true,
|
||||
default: 'mandate',
|
||||
options: [
|
||||
{ value: 'mandate', label: t('Nur dieser Mandant') },
|
||||
{ value: 'global', label: t('Template bei neuen Mandanten') },
|
||||
]
|
||||
});
|
||||
}
|
||||
return fields;
|
||||
}, [backendAttributes, t]);
|
||||
|
||||
// Form attributes from backend - for edit form
|
||||
// NOTE: mandateId/featureInstanceId/featureCode are IMMUTABLE - only description can be edited
|
||||
const editFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
|
||||
|
||||
const fields = backendAttributes
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({
|
||||
...attr,
|
||||
// Mark roleLabel as readonly (cannot change after creation)
|
||||
readonly: attr.name === 'roleLabel' ? true : attr.readonly,
|
||||
})) as AttributeDefinition[];
|
||||
|
||||
// No scope field for edit - context is immutable!
|
||||
return fields;
|
||||
}, [backendAttributes]);
|
||||
|
||||
// Handle create role
|
||||
const handleCreateRole = async (data: { roleLabel: string; description?: Record<string, string>; scope: 'mandate' | 'global' }) => {
|
||||
if (!selectedMandateId) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const roleData: RoleCreate = {
|
||||
roleLabel: data.roleLabel.toLowerCase().replace(/\s+/g, '_'),
|
||||
description: data.description,
|
||||
mandateId: data.scope === 'mandate' ? selectedMandateId : undefined
|
||||
};
|
||||
|
||||
const result = await createRole(roleData, selectedMandateId);
|
||||
|
||||
if (result.success) {
|
||||
setShowCreateModal(false);
|
||||
await fetchRoles(selectedMandateId, { scopeFilter });
|
||||
} else {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Erstellen der Rolle'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Create role error:', err);
|
||||
showError(t('Fehler'), err.message || t('Fehler beim Erstellen der Rolle'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edit role
|
||||
const handleEditRole = async (data: RoleUpdate & { scope?: 'mandate' | 'global' }) => {
|
||||
if (!editingRole) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Convert scope to mandateId - NOTE: Context fields are IMMUTABLE per concept!
|
||||
// We should not be changing mandateId after creation
|
||||
const updateData: RoleUpdate = {
|
||||
roleLabel: data.roleLabel,
|
||||
description: data.description,
|
||||
// mandateId is immutable - don't include in update
|
||||
};
|
||||
|
||||
const result = await updateRole(editingRole.id, updateData);
|
||||
|
||||
if (result.success) {
|
||||
setEditingRole(null);
|
||||
if (selectedMandateId) {
|
||||
await fetchRoles(selectedMandateId, { scopeFilter });
|
||||
}
|
||||
} else {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Rolle'));
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Update role error:', err);
|
||||
showError(t('Fehler'), err.message || t('Fehler beim Aktualisieren der Rolle'));
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete role (confirmation handled by DeleteActionButton)
|
||||
const handleDeleteRole = async (role: Role) => {
|
||||
if (role.isSystemRole) {
|
||||
showWarning(t('Nicht erlaubt'), t('System-Rollen können nicht gelöscht werden.'));
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteRole(role.id);
|
||||
if (result.success) {
|
||||
// Refetch to update the list
|
||||
await fetchRoles(selectedMandateId, { scopeFilter });
|
||||
} else {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Löschen der Rolle'));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edit click
|
||||
const handleEditClick = (role: Role) => {
|
||||
setEditingRole(role);
|
||||
};
|
||||
|
||||
if (error && !selectedMandateId) {
|
||||
return (
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>
|
||||
{t('Fehler')}: {error}
|
||||
</p>
|
||||
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
|
||||
<FaSync /> {t('Erneut versuchen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>{t('Rollen')}</h1>
|
||||
<p className={styles.pageSubtitle}>{t('Verwalten Sie systemweite und globale')}</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => navigate('/admin/mandate-role-permissions')}
|
||||
>
|
||||
<FaShieldAlt /> {t('Rollen-Berechtigungen')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => navigate('/admin/feature-roles')}
|
||||
>
|
||||
<FaCube /> {t('Feature Rollen & Rechte')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mandate Selector and Filters */}
|
||||
<div className={styles.filterSection}>
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>
|
||||
<FaBuilding style={{ marginRight: 8 }} />
|
||||
{t('Mandant')}:
|
||||
</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={selectedMandateId}
|
||||
onChange={(e) => setSelectedMandateId(e.target.value)}
|
||||
>
|
||||
<option value="">{t('Mandant wählen')}</option>
|
||||
{mandates.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{mandateDisplayLabel(m)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>{t('Filter')}</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={scopeFilter}
|
||||
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
|
||||
style={{ minWidth: 150 }}
|
||||
>
|
||||
<option value="mandate">{t('Mandanten-Rollen')}</option>
|
||||
<option value="all">{t('Alle inkl. Templates')}</option>
|
||||
<option value="global">{t('Nur Templates')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedMandateId && (
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => fetchRoles(selectedMandateId, { scopeFilter })}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<FaPlus /> {t('Neue Rolle')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
{selectedMandateId && (
|
||||
<div className={styles.infoBox}>
|
||||
<FaUserShield style={{ marginRight: 8 }} />
|
||||
<span>
|
||||
<strong>{t('System-Templates')}</strong>{' '}
|
||||
{t('(admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert. Templates selbst können nicht gelöscht werden.')}{' '}
|
||||
<strong>{t('Mandanten-Rollen')}</strong>{' '}
|
||||
{t('gelten nur für den ausgewählten Mandanten und sind den Benutzern zuweisbar.')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!selectedMandateId ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaBuilding className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
{t('Wählen Sie einen Mandanten aus, um dessen Rollen zu verwalten.')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableContainer}>
|
||||
<FormGeneratorTable
|
||||
data={roles}
|
||||
columns={columns}
|
||||
apiEndpoint="/api/rbac/roles"
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit' as const,
|
||||
onAction: handleEditClick,
|
||||
title: t('Rolle bearbeiten'),
|
||||
disabled: (row: Role) => row.isSystemRole ? { disabled: true, message: t('System-Rollen können nicht bearbeitet werden') } : false
|
||||
},
|
||||
{
|
||||
type: 'delete' as const,
|
||||
title: t('Rolle löschen'),
|
||||
disabled: (row: Role) => row.isSystemRole ? { disabled: true, message: t('System-Rollen können nicht gelöscht werden') } : false
|
||||
}
|
||||
]}
|
||||
onDelete={handleDeleteRole}
|
||||
hookData={{
|
||||
refetch: refetchWithParams,
|
||||
pagination: pagination,
|
||||
handleDelete: handleDeleteRole,
|
||||
}}
|
||||
emptyMessage={t('Keine Rollen gefunden')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Role Modal */}
|
||||
{showCreateModal && (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>{t('Neue Rolle erstellen')}</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{createFields.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>{t('Lade Formular')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={createFields}
|
||||
mode="create"
|
||||
onSubmit={handleCreateRole}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
submitButtonText={isSubmitting ? t('Erstelle…') : t('Rolle erstellen')}
|
||||
cancelButtonText={t('Abbrechen')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Role Modal */}
|
||||
{editingRole && (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>
|
||||
{t('Rolle bearbeiten')}: {editingRole.roleLabel}
|
||||
</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => setEditingRole(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{editFields.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>{t('Lade Formular')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
|
||||
<FaUserShield style={{ marginRight: 8 }} />
|
||||
<span>
|
||||
{t('Geltungsbereich')}:{' '}
|
||||
<strong>
|
||||
{editingRole.mandateId ? t('Mandanten-Instanz') : t('Template (global)')}
|
||||
</strong>{' '}
|
||||
({t('kann nicht geändert werden')})
|
||||
</span>
|
||||
</div>
|
||||
<FormGeneratorForm
|
||||
attributes={editFields}
|
||||
data={editingRole}
|
||||
mode="edit"
|
||||
onSubmit={handleEditRole}
|
||||
onCancel={() => setEditingRole(null)}
|
||||
submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')}
|
||||
cancelButtonText={t('Abbrechen')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminMandateRolesPage;
|
||||
|
|
@ -4,21 +4,17 @@
|
|||
* Admin page for managing Mandates (tenants) using FormGeneratorTable.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useAdminMandates, useMandateFormAttributes, type Mandate } from '../../hooks/useMandates';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchSettingsAdmin, updateSettingsAdmin } from '../../api/billingApi';
|
||||
import {
|
||||
mergeBillingIntoMandateFormData,
|
||||
splitMandateAndBillingFromForm,
|
||||
} from '../../utils/mandateBillingFormMerge';
|
||||
import { updateSettingsAdmin } from '../../api/billingApi';
|
||||
import { splitMandateAndBillingFromForm } from '../../utils/mandateBillingFormMerge';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { usePrompt } from '../../hooks/usePrompt';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaUsers, FaLock, FaSkullCrossbones } from 'react-icons/fa';
|
||||
import { getUserDataCache } from '../../utils/userCache';
|
||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { MandateExpandDashboard } from '../../components/admin/MandateExpandDashboard';
|
||||
import { FaPlus, FaSync, FaSkullCrossbones } from 'react-icons/fa';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
|
@ -26,7 +22,6 @@ import { useLanguage } from '../../providers/language/LanguageContext';
|
|||
export const AdminMandatesPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { request } = useApiRequest();
|
||||
const { showWarning, showSuccess } = useToast();
|
||||
const { prompt, PromptDialog } = usePrompt();
|
||||
|
|
@ -48,52 +43,42 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
} = useAdminMandates();
|
||||
|
||||
const {
|
||||
formAttributes,
|
||||
createFormAttributes,
|
||||
formAttributesWithBilling,
|
||||
createFormAttributesWithBilling,
|
||||
loading: mandateAttrsLoading,
|
||||
} = useMandateFormAttributes();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
/** Mandate row merged with billing fields for FormGenerator */
|
||||
const [editingFormData, setEditingFormData] = useState<Record<string, unknown> | null>(null);
|
||||
const [editingBillingWarning, setEditingBillingWarning] = useState<string | null>(null);
|
||||
const [expandedMandateIds, setExpandedMandateIds] = useState<Set<string>>(new Set());
|
||||
const [detailRefreshKey, setDetailRefreshKey] = useState(0);
|
||||
|
||||
const isPlatformAdmin = getUserDataCache()?.isPlatformAdmin === true;
|
||||
const isMandateExpanded = useCallback(
|
||||
(mandate: Mandate) => expandedMandateIds.has(mandate.id),
|
||||
[expandedMandateIds],
|
||||
);
|
||||
|
||||
// MandateAdmin: only label + billing fields editable; rest readonly
|
||||
const _MANDATE_ADMIN_EDITABLE = new Set(['label', 'warningThresholdPercent', 'notifyOnWarning', 'notifyEmails']);
|
||||
const editFormAttrs: AttributeDefinition[] = useMemo(() => {
|
||||
if (isPlatformAdmin) return formAttributesWithBilling;
|
||||
return formAttributesWithBilling.map(attr =>
|
||||
_MANDATE_ADMIN_EDITABLE.has(attr.name) ? attr : { ...attr, editable: false, readonly: true }
|
||||
);
|
||||
}, [formAttributesWithBilling, isPlatformAdmin]);
|
||||
const toggleMandateExpand = useCallback((mandate: Mandate) => {
|
||||
setExpandedMandateIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(mandate.id)) {
|
||||
next.delete(mandate.id);
|
||||
} else {
|
||||
next.add(mandate.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleMandateUpdated = useCallback(() => {
|
||||
void refetch();
|
||||
setDetailRefreshKey((k) => k + 1);
|
||||
}, [refetch]);
|
||||
|
||||
// Check if user can create
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
const canUpdate = permissions?.update !== 'n';
|
||||
const canDelete = permissions?.delete !== 'n';
|
||||
|
||||
// Handle edit click — load mandate + billing settings (separate persistence)
|
||||
const handleEditClick = async (mandate: Mandate) => {
|
||||
setEditingBillingWarning(null);
|
||||
const fullMandate = await fetchMandateById(mandate.id);
|
||||
if (!fullMandate) return;
|
||||
try {
|
||||
const settings = await fetchSettingsAdmin(request, fullMandate.id);
|
||||
setEditingFormData(
|
||||
mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, settings)
|
||||
);
|
||||
} catch {
|
||||
setEditingFormData(mergeBillingIntoMandateFormData(fullMandate as Record<string, unknown>, null));
|
||||
setEditingBillingWarning(
|
||||
t('Abrechnungseinstellungen konnten nicht geladen werden. Nur Mandantendaten sind sicher bearbeitbar.')
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle create submit — POST mandate, then billing settings
|
||||
const handleCreateSubmit = async (data: Record<string, unknown>) => {
|
||||
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
||||
|
|
@ -112,27 +97,6 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
setShowCreateModal(false);
|
||||
};
|
||||
|
||||
// Handle edit submit — PUT mandate + POST billing settings
|
||||
const handleEditSubmit = async (data: Record<string, unknown>) => {
|
||||
if (!editingFormData?.id) return;
|
||||
const mandateId = String(editingFormData.id);
|
||||
const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data);
|
||||
const mandateOk = await handleUpdate(mandateId, mandatePayload as Partial<Mandate>);
|
||||
if (!mandateOk) {
|
||||
showWarning(t('Fehler'), t('Mandant konnte nicht gespeichert werden. Fehlende Berechtigung oder Serverfehler.'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateSettingsAdmin(request, mandateId, billingUpdate);
|
||||
showSuccess(t('Gespeichert'), t('Mandant und Abrechnung aktualisiert.'));
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
showWarning(t('Teilweise gespeichert'), t('Mandant gespeichert, Abrechnung konnte nicht aktualisiert werden.'));
|
||||
}
|
||||
setEditingFormData(null);
|
||||
setEditingBillingWarning(null);
|
||||
};
|
||||
|
||||
const handleDeleteMandate = async (mandate: Mandate) => {
|
||||
if (mandate.isSystem) {
|
||||
return;
|
||||
|
|
@ -208,13 +172,6 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => navigate('/admin/user-mandates')}
|
||||
>
|
||||
<FaUsers /> {t('Benutzer-Zuweisungen')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => refetch()}
|
||||
|
|
@ -246,10 +203,13 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
sortable={true}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'expand' as const,
|
||||
title: t('Benutzer anzeigen'),
|
||||
},
|
||||
...(canUpdate ? [{
|
||||
type: 'edit' as const,
|
||||
onAction: handleEditClick,
|
||||
title: t('Bearbeiten'),
|
||||
type: 'roles' as const,
|
||||
title: t('Rollen & Berechtigungen'),
|
||||
}] : []),
|
||||
...(canDelete ? [{
|
||||
type: 'delete' as const,
|
||||
|
|
@ -269,6 +229,16 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
: false,
|
||||
}] : []}
|
||||
onDelete={handleDeleteMandate}
|
||||
renderExpandedRow={(mandate) => (
|
||||
<MandateExpandDashboard
|
||||
mandate={mandate}
|
||||
canUpdate={canUpdate}
|
||||
fetchMandateById={fetchMandateById}
|
||||
handleUpdate={handleUpdate}
|
||||
onMandateUpdated={handleMandateUpdated}
|
||||
refreshKey={detailRefreshKey}
|
||||
/>
|
||||
)}
|
||||
hookData={{
|
||||
refetch,
|
||||
permissions,
|
||||
|
|
@ -276,6 +246,9 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
handleDelete,
|
||||
handleInlineUpdate,
|
||||
updateOptimistically,
|
||||
expandedRowIds: expandedMandateIds,
|
||||
isRowExpanded: isMandateExpanded,
|
||||
toggleExpandedRow: toggleMandateExpand,
|
||||
}}
|
||||
emptyMessage={t('Keine Mandanten gefunden')}
|
||||
/>
|
||||
|
|
@ -320,66 +293,6 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
)}
|
||||
|
||||
<PromptDialog />
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingFormData && (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>{t('Mandant bearbeiten')}</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => {
|
||||
setEditingFormData(null);
|
||||
setEditingBillingWarning(null);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{Boolean(editingFormData.isSystem) && (
|
||||
<div className={styles.infoBox} style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}>
|
||||
<FaLock style={{ marginRight: 8, color: 'var(--warning-color, #d69e2e)' }} />
|
||||
<span>
|
||||
{t('Dies ist ein')} <strong>{t('System-Mandant')}</strong>.{' '}
|
||||
{t(
|
||||
'Er kann nicht gelöscht werden. Das Kurzzeichen (technischer Identifier) soll nicht geändert werden; der Volle Name kann bei Bedarf angepasst werden.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{editingBillingWarning && (
|
||||
<div
|
||||
className={styles.infoBox}
|
||||
style={{ marginBottom: '1rem', background: 'var(--warning-bg, #fffbeb)', borderColor: 'var(--warning-color, #d69e2e)' }}
|
||||
>
|
||||
{editingBillingWarning}
|
||||
</div>
|
||||
)}
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>{t('Lade Formular')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={editFormAttrs}
|
||||
data={editingFormData}
|
||||
mode="edit"
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={() => {
|
||||
setEditingFormData(null);
|
||||
setEditingBillingWarning(null);
|
||||
}}
|
||||
submitButtonText={t('Speichern')}
|
||||
cancelButtonText={t('Abbrechen')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,440 +0,0 @@
|
|||
/**
|
||||
* AdminUserMandatesPage
|
||||
*
|
||||
* Admin page for managing user-mandate memberships.
|
||||
* Allows assigning users to mandates and managing their roles within mandates.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||
import { useUserMandates, type MandateUser, type Mandate, type Role, type PaginationParams } from '../../hooks/useUserMandates';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils';
|
||||
|
||||
export const AdminUserMandatesPage: React.FC = () => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
const { showError } = useToast();
|
||||
const { request } = useApiRequest();
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
fetchMandateUsers,
|
||||
addUserToMandate,
|
||||
removeUserFromMandate,
|
||||
updateUserRoles,
|
||||
fetchMandates,
|
||||
fetchRoles,
|
||||
fetchAllUsers,
|
||||
} = useUserMandates();
|
||||
|
||||
// Store current mandateId for refetch
|
||||
const currentMandateIdRef = useRef<string>('');
|
||||
|
||||
// State
|
||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [allUsers, setAllUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<MandateUser | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
// Load mandates and attributes on mount
|
||||
useEffect(() => {
|
||||
const loadMandates = async () => {
|
||||
const data = await fetchMandates();
|
||||
setMandates(data);
|
||||
// Auto-select first mandate if available
|
||||
if (data.length > 0 && !selectedMandateId) {
|
||||
setSelectedMandateId(data[0].id);
|
||||
}
|
||||
};
|
||||
loadMandates();
|
||||
fetchAttributes(request, 'UserMandateView')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates, request]);
|
||||
|
||||
// Load users when mandate changes
|
||||
useEffect(() => {
|
||||
if (selectedMandateId) {
|
||||
currentMandateIdRef.current = selectedMandateId;
|
||||
fetchMandateUsers(selectedMandateId);
|
||||
fetchRoles(selectedMandateId).then(setRoles);
|
||||
}
|
||||
}, [selectedMandateId, fetchMandateUsers, fetchRoles]);
|
||||
|
||||
// Refetch wrapper that accepts pagination params from FormGeneratorTable
|
||||
const refetchWithParams = useCallback(async (paginationParams?: PaginationParams) => {
|
||||
const mandateId = currentMandateIdRef.current;
|
||||
if (!mandateId) return;
|
||||
// If pagination params provided, pass them; otherwise just use mandateId
|
||||
if (paginationParams && Object.keys(paginationParams).length > 0) {
|
||||
return fetchMandateUsers(paginationParams);
|
||||
}
|
||||
return fetchMandateUsers(mandateId);
|
||||
}, [fetchMandateUsers]);
|
||||
|
||||
// Load all users for the add modal
|
||||
useEffect(() => {
|
||||
fetchAllUsers().then(setAllUsers);
|
||||
}, [fetchAllUsers]);
|
||||
|
||||
// Get users not yet in the mandate
|
||||
const availableUsers = useMemo(() => {
|
||||
const existingUserIds = new Set(users.map(u => u.userId));
|
||||
return allUsers.filter(u => !existingUserIds.has(u.id));
|
||||
}, [allUsers, users]);
|
||||
|
||||
const _rawColumns: ColumnConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'username',
|
||||
label: t('Benutzername'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: t('E-Mail'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
key: 'fullName',
|
||||
label: t('Vollständiger Name'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
key: 'roleLabels',
|
||||
label: t('Rollen'),
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
searchable: true,
|
||||
width: 200,
|
||||
formatter: (value: string[]) => {
|
||||
if (!value || value.length === 0) return '-';
|
||||
return value.join(', ');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: t('Aktiv'),
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: false,
|
||||
width: 80,
|
||||
},
|
||||
], [t]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
// Dynamic options for forms (users and roles)
|
||||
const userOptions = useMemo(() =>
|
||||
availableUsers.map(u => ({
|
||||
value: u.id,
|
||||
label: `${u.username} ${u.email ? `(${u.email})` : ''}`
|
||||
})), [availableUsers]);
|
||||
|
||||
const roleOptions = useMemo(() =>
|
||||
roles.filter(r => !r.featureInstanceId).map(r => ({
|
||||
value: r.id,
|
||||
label: r.roleLabel
|
||||
})), [roles]);
|
||||
|
||||
// Form attributes for adding a user - uses dynamic options
|
||||
// Note: This is an operational form for junction table, not direct model editing
|
||||
const addUserFields: AttributeDefinition[] = useMemo(() => {
|
||||
// Check if backend has userId attribute to get label/description
|
||||
const userIdAttr = backendAttributes.find(a => a.name === 'userId');
|
||||
const roleIdsAttr = backendAttributes.find(a => a.name === 'roleIds');
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'targetUserId',
|
||||
label: userIdAttr?.label || t('Benutzer'),
|
||||
type: 'enum' as any,
|
||||
required: true,
|
||||
options: userOptions,
|
||||
},
|
||||
{
|
||||
name: 'roleIds',
|
||||
label: roleIdsAttr?.label || t('Rollen'),
|
||||
type: 'multiselect' as any,
|
||||
required: true,
|
||||
options: roleOptions,
|
||||
}
|
||||
];
|
||||
}, [userOptions, roleOptions, backendAttributes, t]);
|
||||
|
||||
// Form attributes for editing user roles
|
||||
const editRolesFields: AttributeDefinition[] = useMemo(() => {
|
||||
const roleIdsAttr = backendAttributes.find(a => a.name === 'roleIds');
|
||||
|
||||
return [{
|
||||
name: 'roleIds',
|
||||
label: roleIdsAttr?.label || t('Rollen'),
|
||||
type: 'multiselect' as any,
|
||||
required: true,
|
||||
options: roleOptions,
|
||||
}];
|
||||
}, [roleOptions, backendAttributes, t]);
|
||||
|
||||
// Handle add user submit
|
||||
const handleAddUser = async (data: { targetUserId: string; roleIds: string[] }) => {
|
||||
if (!selectedMandateId) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await addUserToMandate(selectedMandateId, data);
|
||||
if (result.success) {
|
||||
setShowAddModal(false);
|
||||
fetchMandateUsers(selectedMandateId);
|
||||
} else {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Hinzufügen des Benutzers'));
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edit roles submit
|
||||
const handleEditRoles = async (data: { roleIds: string[] }) => {
|
||||
if (!selectedMandateId || !editingUser) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await updateUserRoles(selectedMandateId, editingUser.userId, data.roleIds);
|
||||
if (result.success) {
|
||||
setEditingUser(null);
|
||||
fetchMandateUsers(selectedMandateId);
|
||||
} else {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren der Rollen'));
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle remove user (confirmation handled by DeleteActionButton)
|
||||
const handleRemoveUser = async (user: MandateUser) => {
|
||||
if (!selectedMandateId) return;
|
||||
const result = await removeUserFromMandate(selectedMandateId, user.userId);
|
||||
if (!result.success) {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Entfernen des Benutzers'));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edit click
|
||||
const handleEditClick = (user: MandateUser) => {
|
||||
setEditingUser(user);
|
||||
};
|
||||
|
||||
if (error && !selectedMandateId) {
|
||||
return (
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>
|
||||
{t('Fehler')}: {error}
|
||||
</p>
|
||||
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
|
||||
<FaSync /> {t('Erneut versuchen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>{t('Mandanten-Mitglieder')}</h1>
|
||||
<p className={styles.pageSubtitle}>{t('Verwalten Sie, welche Benutzer Zugriff')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mandate Selector */}
|
||||
<div className={styles.filterSection}>
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>
|
||||
<FaBuilding style={{ marginRight: 8 }} />
|
||||
{t('Mandant auswählen')}:
|
||||
</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={selectedMandateId}
|
||||
onChange={(e) => setSelectedMandateId(e.target.value)}
|
||||
>
|
||||
<option value="">{t('Mandant wählen')}</option>
|
||||
{mandates.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{mandateDisplayLabel(m)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedMandateId && (
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => fetchMandateUsers(selectedMandateId)}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
disabled={availableUsers.length === 0}
|
||||
>
|
||||
<FaPlus /> {t('Benutzer hinzufügen')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{!selectedMandateId ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaBuilding className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>{t('Kein Mandant ausgewählt')}</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
{t('Wählen Sie einen Mandanten aus, um dessen Mitglieder zu verwalten.')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableContainer}>
|
||||
<FormGeneratorTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
apiEndpoint={selectedMandateId ? `/api/mandates/${selectedMandateId}/users` : undefined}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit' as const,
|
||||
onAction: handleEditClick,
|
||||
title: t('Rollen bearbeiten'),
|
||||
},
|
||||
{
|
||||
type: 'delete' as const,
|
||||
title: t('Vom Mandat entfernen'),
|
||||
}
|
||||
]}
|
||||
onDelete={handleRemoveUser}
|
||||
hookData={{
|
||||
refetch: refetchWithParams,
|
||||
pagination: pagination,
|
||||
handleDelete: async (userMandateId: string) => {
|
||||
// Find user by UserMandate ID to get userId for API call
|
||||
const user = users.find(u => u.id === userMandateId);
|
||||
if (user) {
|
||||
const result = await removeUserFromMandate(selectedMandateId, user.userId);
|
||||
return result.success;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}}
|
||||
emptyMessage={t('Keine Mitglieder gefunden')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add User Modal */}
|
||||
{showAddModal && (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>{t('Benutzer zum Mandanten hinzufügen')}</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => setShowAddModal(false)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{availableUsers.length === 0 ? (
|
||||
<p>{t('Alle Benutzer sind bereits diesem')}</p>
|
||||
) : roleOptions.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>{t('Lade Rollen')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={addUserFields}
|
||||
mode="create"
|
||||
onSubmit={handleAddUser}
|
||||
onCancel={() => setShowAddModal(false)}
|
||||
submitButtonText={isSubmitting ? t('Hinzufügen') : t('Hinzufügen')}
|
||||
cancelButtonText={t('Abbrechen')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Roles Modal */}
|
||||
{editingUser && (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>
|
||||
{t('Rollen bearbeiten')}: {editingUser.username}
|
||||
</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => setEditingUser(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
<FormGeneratorForm
|
||||
attributes={editRolesFields}
|
||||
data={{ roleIds: editingUser.roleIds }}
|
||||
mode="edit"
|
||||
onSubmit={handleEditRoles}
|
||||
onCancel={() => setEditingUser(null)}
|
||||
submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')}
|
||||
cancelButtonText={t('Abbrechen')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUserMandatesPage;
|
||||
366
src/pages/admin/AdminUserRoleTemplatesPage.tsx
Normal file
366
src/pages/admin/AdminUserRoleTemplatesPage.tsx
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
/**
|
||||
* AdminUserRoleTemplatesPage
|
||||
*
|
||||
* Manage user role templates (system + global templates with mandateId=null).
|
||||
* Mandate-specific roles are managed per tenant on Admin Mandates (roles popup).
|
||||
* Feature role templates: AdminFeatureRolesPage.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
useMandateRoles,
|
||||
type Role,
|
||||
type RoleCreate,
|
||||
type RoleUpdate,
|
||||
type PaginationParams,
|
||||
} from '../../hooks/useMandateRoles';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaUserShield } from 'react-icons/fa';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useApiRequest } from '../../hooks/useApi';
|
||||
import { fetchAttributes } from '../../api/attributesApi';
|
||||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||||
import type { ColumnConfig } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||||
|
||||
export const AdminUserRoleTemplatesPage: React.FC = () => {
|
||||
const { t, currentLanguage } = useLanguage();
|
||||
const { request } = useApiRequest();
|
||||
const { showError, showWarning } = useToast();
|
||||
const {
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
fetchUserRoleTemplates,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
} = useMandateRoles();
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttributes(request, 'RoleView')
|
||||
.then(setBackendAttributes)
|
||||
.catch(() => setBackendAttributes([]));
|
||||
}, [request]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserRoleTemplates();
|
||||
}, [fetchUserRoleTemplates]);
|
||||
|
||||
const refetchWithParams = useCallback(
|
||||
async (paginationParams?: PaginationParams) => {
|
||||
return fetchUserRoleTemplates(paginationParams);
|
||||
},
|
||||
[fetchUserRoleTemplates],
|
||||
);
|
||||
|
||||
const getDescriptionText = (desc: unknown) => {
|
||||
if (!desc) return '-';
|
||||
if (typeof desc === 'string') return desc;
|
||||
if (typeof desc === 'object') {
|
||||
const record = desc as Record<string, string>;
|
||||
return (
|
||||
record[currentLanguage] ||
|
||||
record['xx'] ||
|
||||
Object.values(record).find(v => typeof v === 'string' && v.trim()) ||
|
||||
'-'
|
||||
);
|
||||
}
|
||||
return String(desc);
|
||||
};
|
||||
|
||||
const _rawColumns: ColumnConfig[] = useMemo(
|
||||
() => [
|
||||
{ key: 'roleLabel', sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{
|
||||
key: 'description',
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
width: 250,
|
||||
formatter: (value: string) => getDescriptionText(value),
|
||||
},
|
||||
{ key: 'scopeType', sortable: true, filterable: true, width: 160 },
|
||||
{ key: 'userCount', sortable: true, filterable: true, width: 100 },
|
||||
],
|
||||
[currentLanguage],
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => resolveColumnTypes(_rawColumns, backendAttributes),
|
||||
[_rawColumns, backendAttributes],
|
||||
);
|
||||
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
|
||||
return backendAttributes
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({ ...attr })) as AttributeDefinition[];
|
||||
}, [backendAttributes]);
|
||||
|
||||
const editFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole', 'scopeType'];
|
||||
return backendAttributes
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({
|
||||
...attr,
|
||||
readonly: attr.name === 'roleLabel' ? true : attr.readonly,
|
||||
})) as AttributeDefinition[];
|
||||
}, [backendAttributes]);
|
||||
|
||||
const handleCreateTemplate = async (data: { roleLabel: string; description?: Record<string, string> }) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const roleData: RoleCreate = {
|
||||
roleLabel: data.roleLabel.toLowerCase().replace(/\s+/g, '_'),
|
||||
description: data.description,
|
||||
mandateId: undefined,
|
||||
};
|
||||
|
||||
const result = await createRole(roleData);
|
||||
if (result.success) {
|
||||
setShowCreateModal(false);
|
||||
await fetchUserRoleTemplates();
|
||||
} else {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Erstellen des Rollen-Templates'));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('Fehler beim Erstellen des Rollen-Templates');
|
||||
showError(t('Fehler'), message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTemplate = async (data: RoleUpdate) => {
|
||||
if (!editingRole) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const updateData: RoleUpdate = {
|
||||
roleLabel: data.roleLabel,
|
||||
description: data.description,
|
||||
};
|
||||
|
||||
const result = await updateRole(editingRole.id, updateData);
|
||||
if (result.success) {
|
||||
setEditingRole(null);
|
||||
await fetchUserRoleTemplates();
|
||||
} else {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Aktualisieren des Rollen-Templates'));
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : t('Fehler beim Aktualisieren des Rollen-Templates');
|
||||
showError(t('Fehler'), message);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = async (roleId: string): Promise<boolean> => {
|
||||
const role = roles.find(r => r.id === roleId);
|
||||
if (role?.isSystemRole) {
|
||||
showWarning(t('Nicht erlaubt'), t('System-Rollen können nicht gelöscht werden.'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const result = await deleteRole(roleId);
|
||||
if (result.success) {
|
||||
await fetchUserRoleTemplates();
|
||||
return true;
|
||||
} else {
|
||||
showError(t('Fehler'), result.error || t('Fehler beim Löschen des Rollen-Templates'));
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditClick = (role: Role) => {
|
||||
setEditingRole(role);
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>
|
||||
{t('Fehler')}: {error}
|
||||
</p>
|
||||
<button type="button" className={styles.secondaryButton} onClick={() => fetchUserRoleTemplates()}>
|
||||
<FaSync /> {t('Erneut versuchen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>{t('User Role Templates')}</h1>
|
||||
<p className={styles.pageSubtitle}>
|
||||
{t('Verwalten Sie System- und globale Rollen-Vorlagen. Diese werden bei neuen Mandanten kopiert.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterSection}>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => fetchUserRoleTemplates()}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<FaPlus /> {t('Neues Rollen-Template')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.infoBox}>
|
||||
<FaUserShield style={{ marginRight: 8 }} />
|
||||
<span>
|
||||
<strong>{t('System-Templates')}</strong>{' '}
|
||||
{t(
|
||||
'(admin, user, viewer) werden bei der Mandant-Erstellung automatisch als Mandanten-Instanz-Rollen kopiert und können hier nicht gelöscht werden.',
|
||||
)}{' '}
|
||||
<strong>{t('Globale Templates')}</strong>{' '}
|
||||
{t('gelten für alle neuen Mandanten. Mandanten-spezifische Rollen verwalten Sie unter Mandanten.')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableContainer}>
|
||||
<FormGeneratorTable
|
||||
data={roles}
|
||||
columns={columns}
|
||||
apiEndpoint="/api/rbac/roles"
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={true}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit' as const,
|
||||
onAction: handleEditClick,
|
||||
title: t('Rollen-Template bearbeiten'),
|
||||
disabled: (row: Role) =>
|
||||
row.isSystemRole
|
||||
? { disabled: true, message: t('System-Rollen können nicht bearbeitet werden') }
|
||||
: false,
|
||||
},
|
||||
{
|
||||
type: 'delete' as const,
|
||||
title: t('Rollen-Template löschen'),
|
||||
disabled: (row: Role) =>
|
||||
row.isSystemRole
|
||||
? { disabled: true, message: t('System-Rollen können nicht gelöscht werden') }
|
||||
: false,
|
||||
},
|
||||
]}
|
||||
onDelete={handleDeleteTemplate}
|
||||
hookData={{
|
||||
refetch: refetchWithParams,
|
||||
pagination: pagination,
|
||||
handleDelete: handleDeleteTemplate,
|
||||
}}
|
||||
emptyMessage={t('Keine Rollen-Templates gefunden')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>{t('Neues Rollen-Template erstellen')}</h2>
|
||||
<button type="button" className={styles.modalClose} onClick={() => setShowCreateModal(false)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{createFields.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>{t('Lade Formular')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={createFields}
|
||||
mode="create"
|
||||
onSubmit={handleCreateTemplate}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
submitButtonText={isSubmitting ? t('Erstelle…') : t('Rollen-Template erstellen')}
|
||||
cancelButtonText={t('Abbrechen')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editingRole && (
|
||||
<div className={styles.modalOverlay}>
|
||||
<div className={styles.modal}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>
|
||||
{t('Rollen-Template bearbeiten')}: {editingRole.roleLabel}
|
||||
</h2>
|
||||
<button type="button" className={styles.modalClose} onClick={() => setEditingRole(null)}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{editFields.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>{t('Lade Formular')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
|
||||
<FaUserShield style={{ marginRight: 8 }} />
|
||||
<span>
|
||||
{t('Geltungsbereich')}:{' '}
|
||||
<strong>
|
||||
{editingRole.isSystemRole ? t('System-Template') : t('Template (global)')}
|
||||
</strong>{' '}
|
||||
({t('kann nicht geändert werden')})
|
||||
</span>
|
||||
</div>
|
||||
<FormGeneratorForm
|
||||
attributes={editFields}
|
||||
data={editingRole}
|
||||
mode="edit"
|
||||
onSubmit={handleEditTemplate}
|
||||
onCancel={() => setEditingRole(null)}
|
||||
submitButtonText={isSubmitting ? t('Speichern') : t('Speichern')}
|
||||
cancelButtonText={t('Abbrechen')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUserRoleTemplatesPage;
|
||||
|
|
@ -7,13 +7,11 @@
|
|||
export { AccessManagementHub } from './AccessManagementHub';
|
||||
export { AdminMandatesPage } from './AdminMandatesPage';
|
||||
export { AdminUsersPage } from './AdminUsersPage';
|
||||
export { AdminUserMandatesPage } from './AdminUserMandatesPage';
|
||||
export { AdminFeatureAccessPage } from './AdminFeatureAccessPage';
|
||||
export { AdminInvitationsPage } from './AdminInvitationsPage';
|
||||
export { AdminMandateRolesPage } from './AdminMandateRolesPage';
|
||||
export { AdminUserRoleTemplatesPage } from './AdminUserRoleTemplatesPage';
|
||||
export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
|
||||
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
|
||||
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
|
||||
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';
|
||||
export { AdminLogsPage } from './AdminLogsPage';
|
||||
export { AdminLanguagesPage } from './AdminLanguagesPage';
|
||||
|
|
|
|||
|
|
@ -64,6 +64,42 @@ function _parseNotifyEmailsInput(val: unknown): string[] {
|
|||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** Formatted multi-line invoice address for read-only display. */
|
||||
export function formatMandateInvoiceAddress(data: Record<string, unknown>): string | null {
|
||||
const lines: string[] = [];
|
||||
const company = data.invoiceCompanyName;
|
||||
const contact = data.invoiceContactName;
|
||||
const line1 = data.invoiceLine1;
|
||||
const line2 = data.invoiceLine2;
|
||||
const postal = data.invoicePostalCode;
|
||||
const city = data.invoiceCity;
|
||||
const state = data.invoiceState;
|
||||
const country = data.invoiceCountry;
|
||||
const vat = data.invoiceVatNumber;
|
||||
const email = data.invoiceEmail;
|
||||
|
||||
if (typeof company === 'string' && company.trim()) lines.push(company.trim());
|
||||
if (typeof contact === 'string' && contact.trim()) lines.push(`z. H. ${contact.trim()}`);
|
||||
if (typeof line1 === 'string' && line1.trim()) lines.push(line1.trim());
|
||||
if (typeof line2 === 'string' && line2.trim()) lines.push(line2.trim());
|
||||
|
||||
const cityLine = [postal, city].filter((v) => typeof v === 'string' && v.trim()).join(' ');
|
||||
if (cityLine.trim()) {
|
||||
const withState =
|
||||
typeof state === 'string' && state.trim() ? `${cityLine.trim()} (${state.trim()})` : cityLine.trim();
|
||||
lines.push(withState);
|
||||
}
|
||||
if (typeof country === 'string' && country.trim()) lines.push(country.trim());
|
||||
if (typeof vat === 'string' && vat.trim()) lines.push(`UID: ${vat.trim()}`);
|
||||
if (typeof email === 'string' && email.trim()) lines.push(email.trim());
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') : null;
|
||||
}
|
||||
|
||||
export function isMandateBillingField(fieldName: string): fieldName is MandateBillingFieldName {
|
||||
return (mandateBillingFieldNames as readonly string[]).includes(fieldName);
|
||||
}
|
||||
|
||||
export function mergeBillingIntoMandateFormData(
|
||||
mandate: Record<string, unknown>,
|
||||
settings: BillingSettings | null
|
||||
|
|
@ -84,8 +120,8 @@ export function mergeBillingIntoMandateFormData(
|
|||
};
|
||||
}
|
||||
|
||||
/** Mandate fields that the AdminMandates form is allowed to update. */
|
||||
const _MANDATE_INVOICE_FIELDS = [
|
||||
/** Mandate invoice address fields (read-only display + form update). */
|
||||
export const MANDATE_INVOICE_FIELD_NAMES = [
|
||||
'invoiceCompanyName',
|
||||
'invoiceContactName',
|
||||
'invoiceEmail',
|
||||
|
|
@ -98,6 +134,8 @@ const _MANDATE_INVOICE_FIELDS = [
|
|||
'invoiceVatNumber',
|
||||
] as const;
|
||||
|
||||
const _MANDATE_INVOICE_FIELDS = MANDATE_INVOICE_FIELD_NAMES;
|
||||
|
||||
/**
|
||||
* Split form submit payload into mandate PUT body and billing POST body.
|
||||
*
|
||||
|
|
@ -114,6 +152,7 @@ export function splitMandateAndBillingFromForm(
|
|||
if ('name' in formData) mandatePayload.name = formData.name;
|
||||
if ('label' in formData) mandatePayload.label = formData.label;
|
||||
if ('enabled' in formData) mandatePayload.enabled = formData.enabled;
|
||||
if ('mfaRequired' in formData) mandatePayload.mfaRequired = formData.mfaRequired;
|
||||
for (const fieldName of _MANDATE_INVOICE_FIELDS) {
|
||||
if (!(fieldName in formData)) continue;
|
||||
const raw = formData[fieldName];
|
||||
|
|
|
|||
Loading…
Reference in a new issue