440 lines
14 KiB
TypeScript
440 lines
14 KiB
TypeScript
/**
|
||
* 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;
|