saas multi mandate tested

This commit is contained in:
ValueOn AG 2026-01-21 21:19:55 +01:00
parent 34d4646667
commit f99b6b7604
3 changed files with 146 additions and 90 deletions

View file

@ -107,8 +107,9 @@ export function useMandateRoles() {
if (Object.keys(paginationWithoutScope).length > 0) { if (Object.keys(paginationWithoutScope).length > 0) {
queryParams.pagination = JSON.stringify(paginationWithoutScope); queryParams.pagination = JSON.stringify(paginationWithoutScope);
} }
// Include templates by default for mandate roles view // Do NOT include feature template roles - they belong to Feature-Rollen page
queryParams.includeTemplates = 'true'; // According to admin_ui_concept.md: Filter: featureCode=null AND featureInstanceId=null
queryParams.includeTemplates = 'false';
// Include mandate-specific roles for the selected mandate // Include mandate-specific roles for the selected mandate
if (mandateId) { if (mandateId) {
queryParams.mandateId = mandateId; queryParams.mandateId = mandateId;

View file

@ -7,7 +7,7 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useFeatureAccess, type FeatureAccessUser, type FeatureInstanceRole, type PaginationParams, type PaginationMetadata } from '../../hooks/useFeatureAccess'; import { useFeatureAccess, type FeatureAccessUser, type FeatureInstanceRole, type PaginationParams, type PaginationMetadata } from '../../hooks/useFeatureAccess';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates'; import { useUserMandates } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUsers, FaBuilding, FaCube } from 'react-icons/fa'; import { FaPlus, FaSync, FaUsers, FaBuilding, FaCube } from 'react-icons/fa';
@ -22,7 +22,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
loading, loading,
error, error,
fetchFeatures, fetchFeatures,
fetchInstances,
fetchInstanceUsers, fetchInstanceUsers,
addUserToInstance, addUserToInstance,
removeUserFromInstance, removeUserFromInstance,
@ -33,10 +32,19 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
const { fetchMandates } = useUserMandates(); const { fetchMandates } = useUserMandates();
const { showSuccess, showError } = useToast(); const { showSuccess, showError } = useToast();
// Combined instance option type
interface CombinedInstanceOption {
mandateId: string;
instanceId: string;
mandateName: string;
instanceLabel: string;
featureCode: string;
combinedKey: string; // mandateId:instanceId for unique identification
}
// State // State
const [mandates, setMandates] = useState<Mandate[]>([]); const [combinedOptions, setCombinedOptions] = useState<CombinedInstanceOption[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>(''); const [selectedCombinedKey, setSelectedCombinedKey] = useState<string>('');
const [selectedInstanceId, setSelectedInstanceId] = useState<string>('');
const [instanceUsers, setInstanceUsers] = useState<FeatureAccessUser[]>([]); const [instanceUsers, setInstanceUsers] = useState<FeatureAccessUser[]>([]);
const [instanceRoles, setInstanceRoles] = useState<FeatureInstanceRole[]>([]); const [instanceRoles, setInstanceRoles] = useState<FeatureInstanceRole[]>([]);
const [allUsers, setAllUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]); const [allUsers, setAllUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]);
@ -46,21 +54,62 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
const [usersLoading, setUsersLoading] = useState(false); const [usersLoading, setUsersLoading] = useState(false);
const [usersPagination, setUsersPagination] = useState<PaginationMetadata | null>(null); const [usersPagination, setUsersPagination] = useState<PaginationMetadata | null>(null);
// Load mandates and features on mount // Extract mandateId and instanceId from combined key
const selectedMandateId = useMemo(() => {
if (!selectedCombinedKey) return '';
return selectedCombinedKey.split(':')[0] || '';
}, [selectedCombinedKey]);
const selectedInstanceId = useMemo(() => {
if (!selectedCombinedKey) return '';
return selectedCombinedKey.split(':')[1] || '';
}, [selectedCombinedKey]);
// Load mandates and features on mount, then build combined options
useEffect(() => { useEffect(() => {
fetchFeatures(); fetchFeatures();
fetchMandates().then(setMandates); const loadCombinedOptions = async () => {
}, [fetchFeatures, fetchMandates]); const loadedMandates = await fetchMandates();
// Load instances when mandate changes // Load instances for all mandates in parallel
useEffect(() => { const allOptions: CombinedInstanceOption[] = [];
if (selectedMandateId) {
fetchInstances(selectedMandateId); for (const mandate of loadedMandates) {
setSelectedInstanceId(''); try {
setInstanceUsers([]); const response = await api.get('/api/features/instances', {
setInstanceRoles([]); headers: { 'X-Mandate-Id': mandate.id }
} });
}, [selectedMandateId, fetchInstances]); const instanceList = response.data?.items || response.data || [];
if (Array.isArray(instanceList)) {
for (const inst of instanceList) {
allOptions.push({
mandateId: mandate.id,
instanceId: inst.id,
mandateName: typeof mandate.name === 'string' ? mandate.name : (mandate.name?.de || mandate.name?.en || Object.values(mandate.name || {})[0] || mandate.id),
instanceLabel: inst.label || inst.id,
featureCode: inst.featureCode,
combinedKey: `${mandate.id}:${inst.id}`,
});
}
}
} catch (err) {
console.error(`Error loading instances for mandate ${mandate.id}:`, err);
}
}
// Sort by mandate name, then by instance label
allOptions.sort((a, b) => {
const mandateCompare = a.mandateName.localeCompare(b.mandateName);
if (mandateCompare !== 0) return mandateCompare;
return a.instanceLabel.localeCompare(b.instanceLabel);
});
setCombinedOptions(allOptions);
};
loadCombinedOptions();
}, [fetchFeatures, fetchMandates]);
// Load users and roles when instance changes // Load users and roles when instance changes
useEffect(() => { useEffect(() => {
@ -297,14 +346,6 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
setEditingUser(user); setEditingUser(user);
}; };
// Get mandate name
const getMandateName = (mandate: Mandate) => {
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
return mandate.name || mandate.id;
};
// Get feature label // Get feature label
const getFeatureLabel = (code: string) => { const getFeatureLabel = (code: string) => {
const feature = features.find(f => f.code === code); const feature = features.find(f => f.code === code);
@ -316,12 +357,24 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
return code; return code;
}; };
// Get selected instance info // Get selected instance info from combined options
const selectedInstance = useMemo(() => { const selectedInstance = useMemo(() => {
return instances.find(i => i.id === selectedInstanceId); const option = combinedOptions.find(o => o.combinedKey === selectedCombinedKey);
}, [instances, selectedInstanceId]); if (!option) return null;
return instances.find(i => i.id === option.instanceId) || {
id: option.instanceId,
label: option.instanceLabel,
featureCode: option.featureCode,
mandateId: option.mandateId,
};
}, [combinedOptions, selectedCombinedKey, instances]);
if (error && !selectedMandateId) { // Get selected combined option for display
const selectedOption = useMemo(() => {
return combinedOptions.find(o => o.combinedKey === selectedCombinedKey);
}, [combinedOptions, selectedCombinedKey]);
if (error && !selectedCombinedKey) {
return ( return (
<div className={styles.adminPage}> <div className={styles.adminPage}>
<div className={styles.errorContainer}> <div className={styles.errorContainer}>
@ -344,50 +397,44 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Selectors */} {/* Combined Selector: Mandate + Feature Instance */}
<div className={styles.filterSection}> <div className={styles.filterSection}>
<div className={styles.filterGroup}> <div className={styles.filterGroup} style={{ flex: 1, maxWidth: 500 }}>
<label className={styles.filterLabel}> <label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} /> <FaCube style={{ marginRight: 8 }} />
Mandant: Mandant / Feature-Instanz:
</label> </label>
<select <select
className={styles.filterSelect} className={styles.filterSelect}
value={selectedMandateId} value={selectedCombinedKey}
onChange={(e) => setSelectedMandateId(e.target.value)} onChange={(e) => setSelectedCombinedKey(e.target.value)}
disabled={loading || combinedOptions.length === 0}
> >
<option value="">-- Mandant wählen --</option> <option value="">-- Mandant / Feature-Instanz wählen --</option>
{mandates.map(m => ( {/* Group options by mandate */}
<option key={m.id} value={m.id}> {(() => {
{getMandateName(m)} const groupedByMandate: Record<string, CombinedInstanceOption[]> = {};
</option> combinedOptions.forEach(opt => {
))} if (!groupedByMandate[opt.mandateName]) {
groupedByMandate[opt.mandateName] = [];
}
groupedByMandate[opt.mandateName].push(opt);
});
return Object.entries(groupedByMandate).map(([mandateName, options]) => (
<optgroup key={mandateName} label={mandateName}>
{options.map(opt => (
<option key={opt.combinedKey} value={opt.combinedKey}>
{opt.instanceLabel} ({getFeatureLabel(opt.featureCode)})
</option>
))}
</optgroup>
));
})()}
</select> </select>
</div> </div>
{selectedMandateId && ( {selectedCombinedKey && (
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaCube style={{ marginRight: 8 }} />
Feature-Instanz:
</label>
<select
className={styles.filterSelect}
value={selectedInstanceId}
onChange={(e) => setSelectedInstanceId(e.target.value)}
disabled={loading || instances.length === 0}
>
<option value="">-- Instanz wählen --</option>
{instances.map(i => (
<option key={i.id} value={i.id}>
{i.label} ({getFeatureLabel(i.featureCode)})
</option>
))}
</select>
</div>
)}
{selectedInstanceId && (
<div className={styles.headerActions}> <div className={styles.headerActions}>
<button <button
className={styles.secondaryButton} className={styles.secondaryButton}
@ -408,9 +455,19 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
</div> </div>
{/* Info box when instance is selected */} {/* Info box when instance is selected */}
{selectedOption && (
<div className={styles.infoBox}>
<FaBuilding style={{ marginRight: 8 }} />
<span>Mandant: <strong>{selectedOption.mandateName}</strong></span>
<span style={{ margin: '0 16px', color: 'var(--color-border)' }}>|</span>
<FaCube style={{ marginRight: 8 }} />
<span>Instanz: <strong>{selectedOption.instanceLabel}</strong> ({selectedOption.featureCode})</span>
</div>
)}
{/* Roles info box */}
{selectedInstance && instanceRoles.length > 0 && ( {selectedInstance && instanceRoles.length > 0 && (
<div className={styles.infoBox}> <div className={styles.infoBox}>
<FaCube style={{ marginRight: 8 }} />
<span>Verfügbare Rollen: </span> <span>Verfügbare Rollen: </span>
{instanceRoles.map((r, i) => ( {instanceRoles.map((r, i) => (
<span key={r.id}> <span key={r.id}>
@ -430,21 +487,13 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => {
)} )}
{/* Content */} {/* Content */}
{!selectedMandateId ? ( {!selectedCombinedKey ? (
<div className={styles.emptyState}>
<FaBuilding className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Kein Mandant ausgewählt</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu sehen.
</p>
</div>
) : !selectedInstanceId ? (
<div className={styles.emptyState}> <div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} /> <FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Feature-Instanz ausgewählt</h3> <h3 className={styles.emptyTitle}>Keine Feature-Instanz ausgewählt</h3>
<p className={styles.emptyDescription}> <p className={styles.emptyDescription}>
{instances.length === 0 {combinedOptions.length === 0
? 'Dieser Mandant hat noch keine Feature-Instanzen.' ? 'Es gibt noch keine Feature-Instanzen. Erstellen Sie zuerst Feature-Instanzen unter "Feature-Instanzen".'
: 'Wählen Sie eine Feature-Instanz aus, um deren Benutzer zu verwalten.'} : 'Wählen Sie eine Feature-Instanz aus, um deren Benutzer zu verwalten.'}
</p> </p>
</div> </div>

View file

@ -18,10 +18,13 @@ import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
interface Feature { interface Feature {
id: string; id?: string;
featureCode: string; code: string; // Backend uses 'code' not 'featureCode'
name: string | { [key: string]: string }; featureCode?: string; // Alias for backward compatibility
label: string | { [key: string]: string }; // Backend uses 'label' not 'name'
name?: string | { [key: string]: string }; // Alias for backward compatibility
description?: string | { [key: string]: string }; description?: string | { [key: string]: string };
icon?: string;
} }
interface FeatureRole { interface FeatureRole {
@ -49,13 +52,13 @@ export const AdminFeatureRolesPage: React.FC = () => {
useEffect(() => { useEffect(() => {
const loadFeatures = async () => { const loadFeatures = async () => {
try { try {
const response = await api.get('/api/features'); const response = await api.get('/api/features/');
const featureList = response.data?.items || response.data || []; const featureList = response.data?.items || response.data || [];
setFeatures(Array.isArray(featureList) ? featureList : []); setFeatures(Array.isArray(featureList) ? featureList : []);
// Auto-select first feature if available // Auto-select first feature if available
if (featureList.length > 0 && !selectedFeatureCode) { if (featureList.length > 0 && !selectedFeatureCode) {
setSelectedFeatureCode(featureList[0].featureCode); setSelectedFeatureCode(featureList[0].code || featureList[0].featureCode || '');
} }
} catch (err: any) { } catch (err: any) {
console.error('Error loading features:', err); console.error('Error loading features:', err);
@ -235,8 +238,8 @@ export const AdminFeatureRolesPage: React.FC = () => {
setEditingRole(role); setEditingRole(role);
}; };
// Get feature name // Get feature name - Backend uses 'label' field
const getFeatureName = (feature: Feature) => getTextValue(feature.name); const getFeatureName = (feature: Feature) => getTextValue(feature.label || feature.name);
if (error && !selectedFeatureCode) { if (error && !selectedFeatureCode) {
return ( return (
@ -274,11 +277,14 @@ export const AdminFeatureRolesPage: React.FC = () => {
onChange={(e) => setSelectedFeatureCode(e.target.value)} onChange={(e) => setSelectedFeatureCode(e.target.value)}
> >
<option value="">-- Feature wählen --</option> <option value="">-- Feature wählen --</option>
{features.map(f => ( {features.map(f => {
<option key={f.id} value={f.featureCode}> const featureCode = f.code || f.featureCode || '';
{getFeatureName(f)} ({f.featureCode}) return (
</option> <option key={featureCode} value={featureCode}>
))} {getFeatureName(f)} ({featureCode})
</option>
);
})}
</select> </select>
</div> </div>