saas multi mandate tested
This commit is contained in:
parent
34d4646667
commit
f99b6b7604
3 changed files with 146 additions and 90 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue