Admin UX: Zugriffsverwaltung-Hub, InstanceDetailModal, PermissionMatrix, Wizard, Sidebar-Links, Hierarchie-Styling (mandateRow Glow/Glassmorph)

This commit is contained in:
Stephan Schellworth 2026-02-03 08:32:48 +01:00
parent 1342bdbcca
commit 83530a44bd
21 changed files with 4116 additions and 4 deletions

View file

@ -11,7 +11,7 @@
* - /admin/* System-Administration (nur SysAdmin) * - /admin/* System-Administration (nur SysAdmin)
*/ */
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { useEffect } from 'react'; import { useEffect } from 'react';
// Import global CSS reset first // Import global CSS reset first
@ -41,7 +41,7 @@ import { DashboardPage } from './pages/Dashboard';
import { SettingsPage } from './pages/Settings'; import { SettingsPage } from './pages/Settings';
import { GDPRPage } from './pages/GDPR'; import { GDPRPage } from './pages/GDPR';
import { FeatureViewPage } from './pages/FeatureView'; import { FeatureViewPage } from './pages/FeatureView';
import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin';
// Workflow Pages (global) // Workflow Pages (global)
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows'; import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
@ -128,6 +128,7 @@ function App() {
{/* ============================================== */} {/* ============================================== */}
{/* MIGRATE TO FEATURES (temporary) */} {/* MIGRATE TO FEATURES (temporary) */}
{/* ============================================== */} {/* ============================================== */}
<Route path="chatbot" element={<Navigate to="/" replace />} />
<Route path="pek" element={<PekPage />} /> <Route path="pek" element={<PekPage />} />
<Route path="speech" element={<SpeechPage />} /> <Route path="speech" element={<SpeechPage />} />
@ -154,6 +155,8 @@ function App() {
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} /> <Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} /> <Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} /> <Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
<Route path="projects" element={<FeatureViewPage view="projects" />} />
<Route path="parcels" element={<FeatureViewPage view="parcels" />} />
{/* Catch-all für unbekannte Sub-Pfade */} {/* Catch-all für unbekannte Sub-Pfade */}
<Route path="*" element={<FeatureViewPage view="not-found" />} /> <Route path="*" element={<FeatureViewPage view="not-found" />} />
@ -163,6 +166,8 @@ function App() {
{/* ADMIN ROUTES (nur SysAdmin) */} {/* ADMIN ROUTES (nur SysAdmin) */}
{/* ============================================== */} {/* ============================================== */}
<Route path="admin"> <Route path="admin">
<Route index element={<Navigate to="/admin/access" replace />} />
<Route path="access" element={<AccessManagementHub />} />
<Route path="mandates" element={<AdminMandatesPage />} /> <Route path="mandates" element={<AdminMandatesPage />} />
<Route path="users" element={<AdminUsersPage />} /> <Route path="users" element={<AdminUsersPage />} />
<Route path="user-mandates" element={<AdminUserMandatesPage />} /> <Route path="user-mandates" element={<AdminUserMandatesPage />} />

View file

@ -47,6 +47,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.system.speech': <FaMicrophone />, 'page.system.speech': <FaMicrophone />,
// Admin pages // Admin pages
'page.admin.access': <FaBuilding />,
'page.admin.users': <FaUsers />, 'page.admin.users': <FaUsers />,
'page.admin.invitations': <FaEnvelopeOpenText />, 'page.admin.invitations': <FaEnvelopeOpenText />,
'page.admin.mandates': <FaBuilding />, 'page.admin.mandates': <FaBuilding />,

View file

@ -0,0 +1,76 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { usePek } from '../hooks/usePek';
interface PekContextType {
// Location input - separate fields
kanton: string;
setKanton: (value: string) => void;
gemeinde: string;
setGemeinde: (value: string) => void;
adresse: string;
setAdresse: (value: string) => void;
buildLocationString: () => string;
// Legacy locationInput for backward compatibility
locationInput: string;
setLocationInput: (value: string) => void;
useCurrentLocation: () => Promise<void>;
isGettingLocation: boolean;
locationError: string | null;
// Parcel search
selectedParcels: any[];
searchParcel: (location: string, includeAdjacent?: boolean) => Promise<any>;
isSearchingParcel: boolean;
parcelSearchError: string | null;
removeParcel: (parcelId: string) => void;
clearSelectedParcels: () => void;
isParcelSelected: (parcelId: string) => boolean;
// Map view
mapCenter: any;
mapZoomBounds: any;
parcelGeometries: any[];
handleMapClick: (point: any) => Promise<void>;
handleParcelClick: (parcelId: string) => Promise<void>;
// Command processing
commandInput: string;
setCommandInput: (value: string) => void;
processCommand: (userInput: string) => Promise<any>;
isProcessingCommand: boolean;
commandResults: any[];
commandError: string | null;
// Project management
currentProjekt: any;
createProjekt: (data: any) => Promise<any>;
isCreatingProjekt: boolean;
addParcelToProjekt: (projektId: string, data: any) => Promise<any>;
isAddingParcel: boolean;
projektError: string | null;
// Panel state
isPanelOpen: boolean;
setIsPanelOpen: (open: boolean) => void;
}
const PekContext = createContext<PekContextType | undefined>(undefined);
export const PekProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const pekData = usePek();
return (
<PekContext.Provider value={pekData}>
{children}
</PekContext.Provider>
);
};
export const usePekContext = (): PekContextType => {
const context = useContext(PekContext);
if (!context) {
throw new Error('usePekContext must be used within a PekProvider');
}
return context;
};

1004
src/hooks/usePek.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,312 @@
/**
* AccessManagementHub glassmorphism and glow
*/
.filters {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.viewModeSwitch {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 0.75rem;
margin-bottom: 1.25rem;
}
.viewModeButtons {
display: flex;
gap: 0.5rem;
}
.viewModeButton,
.viewModeActive {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(8px);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.25s ease;
}
.viewModeButton:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
border-color: rgba(242, 88, 67, 0.25);
box-shadow: 0 0 12px rgba(242, 88, 67, 0.15);
}
.viewModeActive {
background: rgba(242, 88, 67, 0.15);
color: var(--primary-color, #f25843);
border-color: rgba(242, 88, 67, 0.35);
box-shadow: 0 0 14px rgba(242, 88, 67, 0.2);
}
.mandatesLink {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.15);
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(8px);
color: var(--text-secondary);
text-decoration: none;
transition: all 0.25s ease;
}
.mandatesLink:hover {
background: rgba(255, 255, 255, 0.1);
color: var(--text-primary);
border-color: rgba(242, 88, 67, 0.25);
box-shadow: 0 0 12px rgba(242, 88, 67, 0.15);
}
.overviewRow {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1.5rem;
}
.statsCard {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem 1.25rem;
min-width: 140px;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
}
.statsCard:hover {
box-shadow:
0 8px 24px rgba(0, 0, 0, 0.12),
0 0 16px rgba(242, 88, 67, 0.25);
transform: translateY(-2px);
border-color: rgba(242, 88, 67, 0.3);
}
.statsIcon {
font-size: 1.5rem;
color: var(--primary-color, #f25843);
opacity: 0.9;
}
.statsContent {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.statsValue {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary);
}
.statsLabel {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.diagramCard {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 1rem 1.25rem;
flex: 1;
min-width: 200px;
max-width: 400px;
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.06);
}
.diagramContent {
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 0;
}
.diagramTitle {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.diagramFlow {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.diagramNode {
font-size: 0.875rem;
font-weight: 600;
color: var(--primary-color, #f25843);
padding: 0.25rem 0.5rem;
background: rgba(242, 88, 67, 0.15);
border-radius: 6px;
border: 1px solid rgba(242, 88, 67, 0.3);
}
.diagramNodes {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.diagramNodeSmall {
font-size: 0.75rem;
padding: 0.2rem 0.4rem;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
border: 1px solid rgba(255, 255, 255, 0.15);
color: var(--text-secondary);
}
.section {
margin-top: 1.5rem;
}
.sectionTitle {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 1rem 0;
}
.instanceGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.instanceCard {
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 14px;
padding: 1.25rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.instanceCard:hover {
box-shadow:
0 8px 28px rgba(0, 0, 0, 0.12),
0 0 20px rgba(242, 88, 67, 0.2);
transform: translateY(-2px);
border-color: rgba(242, 88, 67, 0.25);
}
.instanceCardHeader {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.instanceLabel {
font-weight: 600;
color: var(--text-primary);
font-size: 1rem;
}
.instanceBadge {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 6px;
font-weight: 500;
}
.badgeActive {
background: rgba(56, 142, 60, 0.2);
color: #388e3c;
border: 1px solid rgba(56, 142, 60, 0.4);
}
.badgeInactive {
background: rgba(158, 158, 158, 0.2);
color: var(--text-secondary);
border: 1px solid rgba(158, 158, 158, 0.3);
}
.instanceMeta {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.instanceActions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: auto;
}
.cardAction {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.4rem 0.75rem;
font-size: 0.8125rem;
background: rgba(242, 88, 67, 0.15);
color: var(--primary-color, #f25843);
border: 1px solid rgba(242, 88, 67, 0.35);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
.cardAction:hover:not(:disabled) {
background: rgba(242, 88, 67, 0.25);
box-shadow: 0 0 12px rgba(242, 88, 67, 0.35);
}
.cardAction:disabled {
opacity: 0.5;
cursor: not-allowed;
}
:global(.dark-theme) .statsCard,
:global(.dark-theme) .diagramCard,
:global(.dark-theme) .instanceCard {
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.1);
}
:global(.dark-theme) .instanceCard:hover {
border-color: rgba(242, 88, 67, 0.4);
}

View file

@ -0,0 +1,566 @@
/**
* AccessManagementHub
*
* Central admin page for feature instance access management.
* Shows mandate/feature context, overview stats, instance cards, and relationship diagram.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
import {
useFeatureAccess,
type FeatureInstance,
type Feature,
type FeatureAccessUser,
} from '../../hooks/useFeatureAccess';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { FaBuilding, FaCube, FaUsers, FaCogs, FaSync, FaChartBar, FaLink, FaList, FaSitemap } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import styles from './Admin.module.css';
import hubStyles from './AccessManagementHub.module.css';
import { InstanceDetailModal } from './InstanceDetailModal';
import { FeatureInstanceWizard } from './FeatureInstanceWizard';
import { InstanceHierarchyView } from './InstanceHierarchyView';
function getMandateName(mandate: Mandate): string {
if (typeof mandate.name === 'object') {
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
}
return mandate.name || mandate.id;
}
function getFeatureLabel(feature: Feature): string {
if (typeof feature.label === 'object') {
return feature.label.de || feature.label.en || feature.code;
}
return feature.label || feature.code;
}
export interface InstanceWithStats extends FeatureInstance {
userCount?: number;
roleCount?: number;
}
export const AccessManagementHub: React.FC = () => {
const {
features,
instances,
loading,
error,
fetchFeatures,
fetchInstances,
syncInstanceRoles,
} = useFeatureAccess();
const { fetchMandates } = useUserMandates();
const { showSuccess, showError } = useToast();
const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [selectedFeatureCode, setSelectedFeatureCode] = useState<string>('');
const [instancesWithStats, setInstancesWithStats] = useState<InstanceWithStats[]>([]);
const [statsLoading, setStatsLoading] = useState(false);
const [detailInstance, setDetailInstance] = useState<{
instance: FeatureInstance;
mandateId: string;
mandateName: string;
featureLabel: string;
} | null>(null);
const [showWizard, setShowWizard] = useState(false);
type ViewMode = 'list' | 'hierarchy';
const [viewMode, setViewMode] = useState<ViewMode>('hierarchy');
const [instanceUsersMap, setInstanceUsersMap] = useState<Record<string, FeatureAccessUser[]>>({});
const [hierarchyUsersLoading, setHierarchyUsersLoading] = useState(false);
const [instancesByMandate, setInstancesByMandate] = useState<Record<string, InstanceWithStats[]>>({});
useEffect(() => {
fetchFeatures();
fetchMandates().then(setMandates);
}, [fetchFeatures, fetchMandates]);
useEffect(() => {
if (selectedMandateId) {
fetchInstances(selectedMandateId, selectedFeatureCode || undefined);
} else {
setInstancesWithStats([]);
}
}, [selectedMandateId, selectedFeatureCode, fetchInstances]);
const loadInstanceStats = useCallback(async (mandateId: string, instanceList: FeatureInstance[]) => {
if (instanceList.length === 0) {
setInstancesWithStats([]);
return;
}
setStatsLoading(true);
try {
const results = await Promise.all(
instanceList.map(async (inst) => {
try {
const [usersRes, rolesRes] = await Promise.all([
api.get(`/api/features/instances/${inst.id}/users`, {
headers: { 'X-Mandate-Id': mandateId },
}),
api.get(`/api/features/instances/${inst.id}/available-roles`, {
headers: { 'X-Mandate-Id': mandateId },
}),
]);
const users = Array.isArray(usersRes.data) ? usersRes.data : usersRes.data?.items || [];
const roles = Array.isArray(rolesRes.data) ? rolesRes.data : [];
return {
...inst,
userCount: users.length,
roleCount: roles.length,
};
} catch {
return { ...inst, userCount: 0, roleCount: 0 };
}
})
);
setInstancesWithStats(results);
} catch {
setInstancesWithStats(instanceList.map((i) => ({ ...i, userCount: 0, roleCount: 0 })));
} finally {
setStatsLoading(false);
}
}, []);
useEffect(() => {
if (selectedMandateId && instances.length > 0 && !loading) {
loadInstanceStats(selectedMandateId, instances);
} else if (instances.length === 0) {
setInstancesWithStats([]);
}
}, [selectedMandateId, instances, loading, loadInstanceStats]);
const handleSyncRoles = async (instance: FeatureInstance) => {
if (!selectedMandateId) return;
try {
const result = await syncInstanceRoles(selectedMandateId, instance.id, true);
if (result.success && result.data) {
showSuccess(
'Rollen synchronisiert',
`Hinzugefügt: ${result.data.added}, Entfernt: ${result.data.removed}, Unverändert: ${result.data.unchanged}`
);
fetchInstances(selectedMandateId, selectedFeatureCode || undefined);
} else {
showError('Synchronisierung fehlgeschlagen', result.error || 'Fehler beim Synchronisieren');
}
} catch {
showError('Fehler', 'Rollen konnten nicht synchronisiert werden');
}
};
const handleOpenDetail = (instance: FeatureInstance, mandateIdOverride?: string) => {
const mandateId = mandateIdOverride ?? selectedMandateId;
const mandate = mandates.find((m) => m.id === mandateId);
const feature = features.find((f) => f.code === instance.featureCode);
setDetailInstance({
instance,
mandateId: mandateId || '',
mandateName: mandate ? getMandateName(mandate) : mandateId || '',
featureLabel: feature ? getFeatureLabel(feature) : instance.featureCode,
});
};
const handleCloseDetail = () => {
setDetailInstance(null);
};
const handleDetailSaved = () => {
if (selectedMandateId) {
fetchInstances(selectedMandateId, selectedFeatureCode || undefined);
}
setDetailInstance(null);
};
const handleWizardComplete = () => {
setShowWizard(false);
if (selectedMandateId) {
fetchInstances(selectedMandateId, selectedFeatureCode || undefined);
}
};
const filteredInstances = useMemo(() => {
if (!selectedFeatureCode) return instancesWithStats;
return instancesWithStats.filter((i) => i.featureCode === selectedFeatureCode);
}, [instancesWithStats, selectedFeatureCode]);
const loadAllHierarchyData = useCallback(async () => {
if (mandates.length === 0) {
setInstancesByMandate({});
setInstanceUsersMap({});
return;
}
setHierarchyUsersLoading(true);
try {
const mandateIds = mandates.map((m) => m.id);
const instancesResults = await Promise.all(
mandateIds.map(async (mandateId) => {
try {
const res = await api.get('/api/features/instances', {
headers: { 'X-Mandate-Id': mandateId },
});
const list = res.data?.items ?? (Array.isArray(res.data) ? res.data : []);
return { mandateId, instances: list as FeatureInstance[] };
} catch {
return { mandateId, instances: [] as FeatureInstance[] };
}
})
);
const byMandate: Record<string, InstanceWithStats[]> = {};
const allInstanceIds: { instanceId: string; mandateId: string }[] = [];
for (const { mandateId, instances } of instancesResults) {
const withStats = await Promise.all(
instances.map(async (inst) => {
try {
const [usersRes, rolesRes] = await Promise.all([
api.get(`/api/features/instances/${inst.id}/users`, {
headers: { 'X-Mandate-Id': mandateId },
}),
api.get(`/api/features/instances/${inst.id}/available-roles`, {
headers: { 'X-Mandate-Id': mandateId },
}),
]);
const users = Array.isArray(usersRes.data) ? usersRes.data : usersRes.data?.items ?? [];
const roles = Array.isArray(rolesRes.data) ? rolesRes.data : [];
allInstanceIds.push({ instanceId: inst.id, mandateId });
return {
...inst,
userCount: users.length,
roleCount: roles.length,
} as InstanceWithStats;
} catch {
allInstanceIds.push({ instanceId: inst.id, mandateId });
return { ...inst, userCount: 0, roleCount: 0 } as InstanceWithStats;
}
})
);
byMandate[mandateId] = withStats;
}
setInstancesByMandate(byMandate);
const usersMap: Record<string, FeatureAccessUser[]> = {};
await Promise.all(
allInstanceIds.map(async ({ instanceId, mandateId }) => {
try {
const res = await api.get(`/api/features/instances/${instanceId}/users`, {
headers: { 'X-Mandate-Id': mandateId },
});
const users = Array.isArray(res.data) ? res.data : res.data?.items ?? [];
usersMap[instanceId] = users as FeatureAccessUser[];
} catch {
usersMap[instanceId] = [];
}
})
);
setInstanceUsersMap(usersMap);
} finally {
setHierarchyUsersLoading(false);
}
}, [mandates]);
useEffect(() => {
if (viewMode === 'hierarchy' && mandates.length > 0) {
loadAllHierarchyData();
} else if (viewMode !== 'hierarchy') {
setInstancesByMandate({});
setInstanceUsersMap({});
}
}, [viewMode, mandates, loadAllHierarchyData]);
const overviewStats = useMemo(() => {
const totalUsers = filteredInstances.reduce((sum, i) => sum + (i.userCount ?? 0), 0);
const maxRoles = Math.max(0, ...filteredInstances.map((i) => i.roleCount ?? 0));
return {
instances: filteredInstances.length,
users: totalUsers,
roles: maxRoles,
};
}, [filteredInstances]);
const relationshipData = useMemo(() => {
if (!selectedMandateId || filteredInstances.length === 0) return null;
const mandate = mandates.find((m) => m.id === selectedMandateId);
return {
mandateName: mandate ? getMandateName(mandate) : selectedMandateId,
instances: filteredInstances.map((inst) => {
const feature = features.find((f) => f.code === inst.featureCode);
return {
id: inst.id,
label: inst.label,
featureLabel: feature ? getFeatureLabel(feature) : inst.featureCode,
userCount: inst.userCount ?? 0,
};
}),
};
}, [selectedMandateId, mandates, filteredInstances, features]);
if (error && !selectedMandateId) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler: {error}</p>
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Zugriffsverwaltung</h1>
<p className={styles.pageSubtitle}>
Feature-Instanzen, Benutzer und Rollen an einem Ort verwalten
</p>
</div>
</div>
<div className={hubStyles.filters}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaBuilding style={{ marginRight: 8 }} />
Mandant:
</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
<option value="">-- Mandant wählen --</option>
{mandates.map((m) => (
<option key={m.id} value={m.id}>
{getMandateName(m)}
</option>
))}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaCube style={{ marginRight: 8 }} />
Feature:
</label>
<select
className={styles.filterSelect}
value={selectedFeatureCode}
onChange={(e) => setSelectedFeatureCode(e.target.value)}
>
<option value="">Alle</option>
{features.map((f) => (
<option key={f.code} value={f.code}>
{getFeatureLabel(f)}
</option>
))}
</select>
</div>
{selectedMandateId && (
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() =>
fetchInstances(selectedMandateId, selectedFeatureCode || undefined)
}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
<button
className={styles.primaryButton}
onClick={() => setShowWizard(true)}
disabled={features.length === 0}
>
+ Neue Instanz erstellen
</button>
</div>
)}
</div>
<div className={hubStyles.viewModeSwitch}>
<div className={hubStyles.viewModeButtons}>
<button
type="button"
className={viewMode === 'list' ? hubStyles.viewModeActive : hubStyles.viewModeButton}
onClick={() => setViewMode('list')}
>
<FaList /> Listenansicht
</button>
<button
type="button"
className={viewMode === 'hierarchy' ? hubStyles.viewModeActive : hubStyles.viewModeButton}
onClick={() => setViewMode('hierarchy')}
>
<FaSitemap /> Hierarchie
</button>
</div>
<Link to="/admin/mandates" className={hubStyles.mandatesLink}>
<FaBuilding /> Mandanten verwalten
</Link>
</div>
{viewMode === 'hierarchy' ? (
<InstanceHierarchyView
mandates={mandates}
getMandateName={getMandateName}
instancesByMandate={instancesByMandate}
instanceUsersMap={instanceUsersMap}
features={features}
getFeatureLabel={getFeatureLabel}
loading={hierarchyUsersLoading}
onOpenDetail={handleOpenDetail}
/>
) : !selectedMandateId ? (
<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, um dessen Feature-Instanzen und Zugriffe zu verwalten.
</p>
</div>
) : (
<>
<div className={hubStyles.overviewRow}>
<div className={hubStyles.statsCard}>
<FaChartBar className={hubStyles.statsIcon} />
<div className={hubStyles.statsContent}>
<span className={hubStyles.statsValue}>
{loading || statsLoading ? '…' : overviewStats.instances}
</span>
<span className={hubStyles.statsLabel}>Instanzen</span>
</div>
</div>
<div className={hubStyles.statsCard}>
<FaUsers className={hubStyles.statsIcon} />
<div className={hubStyles.statsContent}>
<span className={hubStyles.statsValue}>
{loading || statsLoading ? '…' : overviewStats.users}
</span>
<span className={hubStyles.statsLabel}>Benutzer</span>
</div>
</div>
<div className={hubStyles.statsCard}>
<FaCogs className={hubStyles.statsIcon} />
<div className={hubStyles.statsContent}>
<span className={hubStyles.statsValue}>
{loading || statsLoading ? '…' : overviewStats.roles}
</span>
<span className={hubStyles.statsLabel}>Rollen (max)</span>
</div>
</div>
{relationshipData && relationshipData.instances.length > 0 && (
<div className={hubStyles.diagramCard}>
<FaLink className={hubStyles.statsIcon} />
<div className={hubStyles.diagramContent}>
<span className={hubStyles.diagramTitle}>Beziehungen</span>
<div className={hubStyles.diagramFlow}>
<div className={hubStyles.diagramNode}>{relationshipData.mandateName}</div>
<div className={hubStyles.diagramNodes}>
{relationshipData.instances.slice(0, 5).map((inst) => (
<div key={inst.id} className={hubStyles.diagramNodeSmall}>
{inst.label} ({inst.userCount})
</div>
))}
{relationshipData.instances.length > 5 && (
<div className={hubStyles.diagramNodeSmall}>
+{relationshipData.instances.length - 5} weitere
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
<section className={hubStyles.section}>
<h2 className={hubStyles.sectionTitle}>Feature-Instanzen</h2>
{loading && filteredInstances.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Instanzen...</span>
</div>
) : filteredInstances.length === 0 ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Feature-Instanzen</h3>
<p className={styles.emptyDescription}>
Erstellen Sie eine neue Instanz oder wählen Sie ein anderes Feature.
</p>
<button
className={styles.primaryButton}
onClick={() => setShowWizard(true)}
disabled={features.length === 0}
>
+ Erste Instanz erstellen
</button>
</div>
) : (
<div className={hubStyles.instanceGrid}>
{filteredInstances.map((inst) => (
<div key={inst.id} className={hubStyles.instanceCard}>
<div className={hubStyles.instanceCardHeader}>
<span className={hubStyles.instanceLabel}>{inst.label}</span>
<span
className={`${hubStyles.instanceBadge} ${inst.enabled ? hubStyles.badgeActive : hubStyles.badgeInactive}`}
>
{inst.enabled ? 'Aktiv' : 'Inaktiv'}
</span>
</div>
<div className={hubStyles.instanceMeta}>
<span>{getFeatureLabel(features.find((f) => f.code === inst.featureCode) || { code: inst.featureCode, label: inst.featureCode })}</span>
<span>{inst.userCount ?? '—'} Benutzer</span>
<span>{inst.roleCount ?? '—'} Rollen</span>
</div>
<div className={hubStyles.instanceActions}>
<button
type="button"
className={hubStyles.cardAction}
onClick={() => handleOpenDetail(inst, selectedMandateId)}
>
<FaUsers /> Benutzer verwalten
</button>
<button
type="button"
className={hubStyles.cardAction}
onClick={() => handleSyncRoles(inst)}
disabled={!inst.enabled}
title="Rollen synchronisieren"
>
<FaCogs /> Rollen sync
</button>
</div>
</div>
))}
</div>
)}
</section>
</>
)}
{detailInstance && (
<InstanceDetailModal
instance={detailInstance.instance}
mandateId={detailInstance.mandateId}
mandateName={detailInstance.mandateName}
featureLabel={detailInstance.featureLabel}
onClose={handleCloseDetail}
onSaved={handleDetailSaved}
/>
)}
{showWizard && (
<FeatureInstanceWizard
mandateId={selectedMandateId}
mandates={mandates}
features={features}
onClose={() => setShowWizard(false)}
onComplete={handleWizardComplete}
/>
)}
</div>
);
};
export default AccessManagementHub;

View file

@ -14,16 +14,18 @@
*/ */
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react'; 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 { useMandateRoles, type Role, type RoleCreate, type RoleUpdate, type PaginationParams } from '../../hooks/useMandateRoles';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates'; import { useUserMandates, type Mandate } 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, FaUserShield, FaBuilding, FaGlobe } from 'react-icons/fa'; import { FaPlus, FaSync, FaUserShield, FaBuilding, FaGlobe, FaShieldAlt, FaCube } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext'; import { useToast } from '../../contexts/ToastContext';
import api from '../../api'; import api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
export const AdminMandateRolesPage: React.FC = () => { export const AdminMandateRolesPage: React.FC = () => {
const navigate = useNavigate();
const { showError, showWarning } = useToast(); const { showError, showWarning } = useToast();
const { const {
roles, roles,
@ -310,6 +312,22 @@ export const AdminMandateRolesPage: React.FC = () => {
<h1 className={styles.pageTitle}>Rollen</h1> <h1 className={styles.pageTitle}>Rollen</h1>
<p className={styles.pageSubtitle}>Verwalten Sie System-, globale und mandantenspezifische Rollen</p> <p className={styles.pageSubtitle}>Verwalten Sie System-, globale und mandantenspezifische Rollen</p>
</div> </div>
<div className={styles.headerActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/mandate-role-permissions')}
>
<FaShieldAlt /> Rollen-Berechtigungen
</button>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/feature-roles')}
>
<FaCube /> Feature Rollen & Rechte
</button>
</div>
</div> </div>
{/* Mandate Selector and Filters */} {/* Mandate Selector and Filters */}

View file

@ -5,10 +5,11 @@
*/ */
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useOrgUsers, useUserOperations } from '../../hooks/useUsers'; import { useOrgUsers, useUserOperations } from '../../hooks/useUsers';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUsers, FaKey } from 'react-icons/fa'; import { FaPlus, FaSync, FaUsers, FaKey, FaEnvelopeOpenText } from 'react-icons/fa';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
interface User { interface User {
@ -22,6 +23,7 @@ interface User {
} }
export const AdminUsersPage: React.FC = () => { export const AdminUsersPage: React.FC = () => {
const navigate = useNavigate();
// Use two hooks: one for data, one for operations // Use two hooks: one for data, one for operations
const { const {
data: users, data: users,
@ -143,6 +145,13 @@ export const AdminUsersPage: React.FC = () => {
<p className={styles.pageSubtitle}>Verwalten Sie alle Benutzer im System</p> <p className={styles.pageSubtitle}>Verwalten Sie alle Benutzer im System</p>
</div> </div>
<div className={styles.headerActions}> <div className={styles.headerActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={() => navigate('/admin/invitations')}
>
<FaEnvelopeOpenText /> Einladungen
</button>
<button <button
className={styles.secondaryButton} className={styles.secondaryButton}
onClick={() => refetch()} onClick={() => refetch()}

View file

@ -0,0 +1,103 @@
.modal {
max-width: 520px;
}
.steps {
display: flex;
justify-content: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border-color);
background: rgba(255, 255, 255, 0.03);
}
.stepDot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
font-weight: 600;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
color: var(--text-secondary);
}
.stepDotActive {
background: rgba(242, 88, 67, 0.2);
border-color: rgba(242, 88, 67, 0.5);
color: var(--primary-color, #f25843);
box-shadow: 0 0 12px rgba(242, 88, 67, 0.3);
}
.stepContent {
display: flex;
flex-direction: column;
gap: 1rem;
}
.stepText {
margin: 0;
font-size: 0.875rem;
color: var(--text-secondary);
}
.checkLabel {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text-secondary);
cursor: pointer;
}
.checkLabel input {
width: 1rem;
height: 1rem;
}
.stepActions {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-top: 0.5rem;
}
.userList {
display: flex;
flex-direction: column;
gap: 0.5rem;
max-height: 280px;
overflow-y: auto;
}
.userRow {
display: flex;
align-items: center;
gap: 1rem;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.userName {
flex: 0 0 140px;
font-size: 0.875rem;
color: var(--text-primary);
}
.roleSelect {
flex: 1;
padding: 0.4rem 0.6rem;
font-size: 0.875rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
}
.roleSelect:focus {
outline: none;
border-color: var(--primary-color);
}

View file

@ -0,0 +1,294 @@
/**
* FeatureInstanceWizard
*
* Guided flow: Create instance Sync roles Add users (optional).
*/
import React, { useState, useMemo } from 'react';
import { useFeatureAccess } from '../../hooks/useFeatureAccess';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import type { Mandate } from '../../hooks/useUserMandates';
import type { Feature } from '../../hooks/useFeatureAccess';
import styles from './Admin.module.css';
import wizardStyles from './FeatureInstanceWizard.module.css';
function getMandateName(m: Mandate): string {
if (typeof m.name === 'object') return m.name.de || m.name.en || Object.values(m.name)[0] || m.id;
return m.name || m.id;
}
function getFeatureLabel(f: Feature): string {
if (typeof f.label === 'object') return f.label.de || f.label.en || f.code;
return f.label || f.code;
}
export interface FeatureInstanceWizardProps {
mandateId: string;
mandates: Mandate[];
features: Feature[];
onClose: () => void;
onComplete: () => void;
}
const STEPS = [
{ id: 'create', title: 'Instanz erstellen' },
{ id: 'roles', title: 'Rollen' },
{ id: 'users', title: 'Benutzer (optional)' },
];
export const FeatureInstanceWizard: React.FC<FeatureInstanceWizardProps> = ({
mandateId: initialMandateId,
mandates,
features,
onClose,
onComplete,
}) => {
const { createInstance, addUserToInstance, fetchInstanceRoles } = useFeatureAccess();
const { showSuccess, showError } = useToast();
const [step, setStep] = useState(0);
const [mandateId, setMandateId] = useState(initialMandateId || '');
const [featureCode, setFeatureCode] = useState('');
const [label, setLabel] = useState('');
const [enabled, setEnabled] = useState(true);
const [copyTemplateRoles, setCopyTemplateRoles] = useState(true);
const [createdInstanceId, setCreatedInstanceId] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [mandateUsers, setMandateUsers] = useState<Array<{ id: string; username: string; email?: string }>>([]);
const [instanceRoles, setInstanceRoles] = useState<Array<{ id: string; roleLabel: string }>>([]);
const [selectedUserRoles, setSelectedUserRoles] = useState<Array<{ userId: string; roleIds: string[] }>>([]);
const featureOptions = useMemo(
() => features.map((f) => ({ value: f.code, label: getFeatureLabel(f) })),
[features]
);
const mandateOptions = useMemo(
() => mandates.map((m) => ({ value: m.id, label: getMandateName(m) })),
[mandates]
);
const createFields: AttributeDefinition[] = useMemo(
() => [
{ name: 'mandateId', label: 'Mandant', type: 'enum' as const, required: true, options: mandateOptions },
{ name: 'featureCode', label: 'Feature', type: 'enum' as const, required: true, options: featureOptions },
{ name: 'label', label: 'Bezeichnung', type: 'string' as const, required: true, editable: true },
{ name: 'enabled', label: 'Aktiv', type: 'boolean' as const, required: false, editable: true },
],
[mandateOptions, featureOptions]
);
const handleStep1Submit = async (data: {
mandateId: string;
featureCode: string;
label: string;
enabled?: boolean;
}) => {
setSubmitting(true);
try {
const result = await createInstance(data.mandateId, {
featureCode: data.featureCode,
label: data.label,
enabled: data.enabled !== false,
copyTemplateRoles: copyTemplateRoles,
});
if (result.success && result.data) {
setMandateId(data.mandateId);
setFeatureCode(data.featureCode);
setLabel(data.label);
setEnabled(data.enabled !== false);
setCreatedInstanceId(result.data.id);
setStep(1);
} else {
showError('Fehler', result.error || 'Instanz konnte nicht erstellt werden');
}
} finally {
setSubmitting(false);
}
};
const handleStep2Next = async () => {
if (createdInstanceId && mandateId) {
setSubmitting(true);
try {
const [roleList, usersRes] = await Promise.all([
fetchInstanceRoles(mandateId, createdInstanceId),
api.get(`/api/mandates/${mandateId}/users`),
]);
setInstanceRoles(Array.isArray(roleList) ? roleList : []);
const data = usersRes.data?.items || usersRes.data || [];
setMandateUsers(
Array.isArray(data)
? data.map((u: { userId: string; username: string; email?: string }) => ({
id: u.userId,
username: u.username,
email: u.email,
}))
: []
);
} catch {
setInstanceRoles([]);
setMandateUsers([]);
} finally {
setSubmitting(false);
}
}
setStep(2);
};
const handleStep3Complete = async () => {
if (!createdInstanceId || !mandateId) {
onComplete();
return;
}
setSubmitting(true);
try {
for (const { userId, roleIds } of selectedUserRoles) {
if (roleIds.length > 0) {
await addUserToInstance(mandateId, createdInstanceId, { userId, roleIds });
}
}
showSuccess('Fertig', 'Feature-Instanz wurde erstellt und Benutzer zugewiesen.');
onComplete();
} catch {
showError('Fehler', 'Einige Benutzer konnten nicht zugewiesen werden.');
} finally {
setSubmitting(false);
}
};
const handleAddUserRole = (userId: string, roleIds: string[]) => {
setSelectedUserRoles((prev) => {
const rest = prev.filter((p) => p.userId !== userId);
if (roleIds.length === 0) return rest;
return [...rest, { userId, roleIds }];
});
};
const currentStepId = STEPS[step]?.id;
return (
<div className={styles.modalOverlay} onClick={onClose}>
<div className={`${styles.modal} ${wizardStyles.modal}`} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Neue Feature-Instanz</h2>
<button type="button" className={styles.modalClose} onClick={onClose} aria-label="Schließen">
</button>
</div>
<div className={wizardStyles.steps}>
{STEPS.map((s, i) => (
<div
key={s.id}
className={`${wizardStyles.stepDot} ${i <= step ? wizardStyles.stepDotActive : ''}`}
title={s.title}
>
{i + 1}
</div>
))}
</div>
<div className={styles.modalContent}>
{currentStepId === 'create' && (
<div className={wizardStyles.stepContent}>
<FormGeneratorForm
attributes={createFields}
mode="create"
data={{
mandateId: mandateId || (mandates[0]?.id ?? ''),
featureCode: featureCode || (features[0]?.code ?? ''),
label,
enabled,
}}
onSubmit={handleStep1Submit}
onCancel={onClose}
submitButtonText="Weiter"
cancelButtonText="Abbrechen"
/>
<label className={wizardStyles.checkLabel}>
<input
type="checkbox"
checked={copyTemplateRoles}
onChange={(e) => setCopyTemplateRoles(e.target.checked)}
/>
Rollen von Feature-Vorlage übernehmen (empfohlen)
</label>
</div>
)}
{currentStepId === 'roles' && (
<div className={wizardStyles.stepContent}>
<p className={wizardStyles.stepText}>
Die Rollen wurden beim Erstellen der Instanz übernommen. Sie können später unter Benutzer verwalten weitere Rollen synchronisieren.
</p>
<div className={wizardStyles.stepActions}>
<button type="button" className={styles.secondaryButton} onClick={() => setStep(0)}>
Zurück
</button>
<button type="button" className={styles.primaryButton} onClick={handleStep2Next}>
Weiter
</button>
</div>
</div>
)}
{currentStepId === 'users' && (
<div className={wizardStyles.stepContent}>
<p className={wizardStyles.stepText}>
Optional: Weisen Sie Benutzern Rollen zu. Sie können dies auch später in der Zugriffsverwaltung tun.
</p>
{mandateUsers.length === 0 ? (
<p className={wizardStyles.stepText}>Keine Mandanten-Benutzer vorhanden.</p>
) : (
<div className={wizardStyles.userList}>
{mandateUsers.map((u) => {
const selected = selectedUserRoles.find((s) => s.userId === u.id);
const roleIds = selected?.roleIds ?? [];
return (
<div key={u.id} className={wizardStyles.userRow}>
<span className={wizardStyles.userName}>{u.username}</span>
<select
className={wizardStyles.roleSelect}
value={roleIds[0] ?? ''}
onChange={(e) => {
const roleId = e.target.value;
const rids = roleId ? [roleId] : [];
handleAddUserRole(u.id, rids);
}}
>
<option value=""> Keine Rolle </option>
{instanceRoles.map((r) => (
<option key={r.id} value={r.id}>
{r.roleLabel}
</option>
))}
</select>
</div>
);
})}
</div>
)}
<div className={wizardStyles.stepActions}>
<button type="button" className={styles.secondaryButton} onClick={() => setStep(1)}>
Zurück
</button>
<button
type="button"
className={styles.primaryButton}
onClick={handleStep3Complete}
disabled={submitting}
>
{submitting ? 'Speichern…' : 'Erstellen'}
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default FeatureInstanceWizard;

View file

@ -0,0 +1,31 @@
.modal {
max-width: 720px;
}
.subtitle {
margin: 0.25rem 0 0 0;
font-size: 0.8125rem;
color: var(--text-secondary);
}
.tabContent {
padding: 0.5rem 0;
min-height: 200px;
}
.rolesIntro {
margin: 0 0 1rem 0;
font-size: 0.875rem;
color: var(--text-secondary);
}
.rolesList {
margin: 0 0 1rem 0;
padding-left: 1.25rem;
font-size: 0.875rem;
color: var(--text-primary);
}
.rolesList li {
margin-bottom: 0.25rem;
}

View file

@ -0,0 +1,367 @@
/**
* InstanceDetailModal
*
* Modal for a feature instance: Benutzer (PermissionMatrix), Rollen (sync), Einstellungen.
*/
import React, { useState, useEffect, useMemo } from 'react';
import { useFeatureAccess, type FeatureInstance, type FeatureAccessUser } from '../../hooks/useFeatureAccess';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { Tabs } from '../../components/UiComponents/Tabs';
import { FaSync } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import { PermissionMatrix } from './PermissionMatrix';
import styles from './Admin.module.css';
import modalStyles from './InstanceDetailModal.module.css';
export interface InstanceDetailModalProps {
instance: FeatureInstance;
mandateId: string;
mandateName: string;
featureLabel: string;
onClose: () => void;
onSaved: () => void;
}
export const InstanceDetailModal: React.FC<InstanceDetailModalProps> = ({
instance,
mandateId,
mandateName,
featureLabel,
onClose,
onSaved,
}) => {
const {
fetchInstanceUsers,
fetchInstanceRoles,
addUserToInstance,
removeUserFromInstance,
updateInstanceUserRoles,
syncInstanceRoles,
updateInstance,
} = useFeatureAccess();
const { showSuccess, showError } = useToast();
const [users, setUsers] = useState<FeatureAccessUser[]>([]);
const [roles, setRoles] = useState<Array<{ id: string; roleLabel: string }>>([]);
const [allUsers, setAllUsers] = useState<Array<{ id: string; username: string; email?: string }>>([]);
const [loading, setLoading] = useState(true);
const [showAddModal, setShowAddModal] = useState(false);
const [editingUser, setEditingUser] = useState<FeatureAccessUser | null>(null);
const [syncing, setSyncing] = useState(false);
const [roleOptions, setRoleOptions] = useState<AttributeDefinition['options']>([]);
const loadData = () => {
setLoading(true);
Promise.all([
fetchInstanceUsers(mandateId, instance.id),
fetchInstanceRoles(mandateId, instance.id),
])
.then(([userList, roleList]) => {
setUsers(Array.isArray(userList) ? userList : []);
setRoles(Array.isArray(roleList) ? roleList : []);
setRoleOptions(
(Array.isArray(roleList) ? roleList : []).map((r) => ({
value: r.id,
label: r.roleLabel,
}))
);
})
.catch(() => {
setUsers([]);
setRoles([]);
setRoleOptions([]);
})
.finally(() => setLoading(false));
};
useEffect(() => {
loadData();
}, [mandateId, instance.id]);
useEffect(() => {
api
.get(`/api/mandates/${mandateId}/users`)
.then((res) => {
const data = res.data?.items || res.data || [];
setAllUsers(
Array.isArray(data)
? data.map((u: { userId: string; username: string; email?: string }) => ({
id: u.userId,
username: u.username,
email: u.email,
}))
: []
);
})
.catch(() => setAllUsers([]));
}, [mandateId]);
const availableUsers = useMemo(() => {
const ids = new Set(users.map((u) => u.userId));
return allUsers.filter((u) => !ids.has(u.id));
}, [allUsers, users]);
const handleAddUser = async (data: { userId: string; roleIds: string[] }) => {
const result = await addUserToInstance(mandateId, instance.id, {
userId: data.userId,
roleIds: data.roleIds,
});
if (result.success) {
setShowAddModal(false);
loadData();
onSaved();
showSuccess('Benutzer hinzugefügt', 'Der Benutzer wurde der Instanz hinzugefügt.');
} else {
showError('Fehler', result.error || 'Fehler beim Hinzufügen');
}
};
const handleRemoveUser = async (user: FeatureAccessUser) => {
const result = await removeUserFromInstance(mandateId, instance.id, user.userId);
if (result.success) {
loadData();
onSaved();
showSuccess('Benutzer entfernt', `"${user.username}" wurde entfernt.`);
} else {
showError('Fehler', result.error || 'Fehler beim Entfernen');
}
};
const handleEditUser = (user: FeatureAccessUser) => {
setEditingUser(user);
};
const handleUpdateRoles = async (data: { roleIds: string[]; enabled?: boolean }) => {
if (!editingUser) return;
const result = await updateInstanceUserRoles(mandateId, instance.id, editingUser.userId, {
roleIds: data.roleIds,
enabled: data.enabled,
});
if (result.success) {
setEditingUser(null);
loadData();
onSaved();
showSuccess('Aktualisiert', 'Rollen und Status wurden gespeichert.');
} else {
showError('Fehler', result.error || 'Fehler beim Speichern');
}
};
const handleSyncRoles = async () => {
setSyncing(true);
try {
const result = await syncInstanceRoles(mandateId, instance.id, true);
if (result.success && result.data) {
loadData();
onSaved();
showSuccess(
'Rollen synchronisiert',
`Hinzugefügt: ${result.data.added}, Entfernt: ${result.data.removed}`
);
} else {
showError('Fehler', result.error || 'Synchronisierung fehlgeschlagen');
}
} finally {
setSyncing(false);
}
};
const handleUpdateInstance = async (data: { label?: string; enabled?: boolean }) => {
const result = await updateInstance(mandateId, instance.id, data);
if (result.success) {
onSaved();
showSuccess('Instanz aktualisiert', 'Einstellungen wurden gespeichert.');
} else {
showError('Fehler', result.error || 'Fehler beim Speichern');
}
};
const addUserFields: AttributeDefinition[] = useMemo(
() => [
{
name: 'userId',
label: 'Benutzer',
type: 'enum' as const,
required: true,
options: availableUsers.map((u) => ({
value: u.id,
label: `${u.username}${u.email ? ` (${u.email})` : ''}`,
})),
},
{
name: 'roleIds',
label: 'Rollen',
type: 'multiselect' as const,
required: true,
options: roleOptions as AttributeDefinition['options'],
},
],
[availableUsers, roleOptions]
);
const editRolesFields: AttributeDefinition[] = useMemo(
() => [
{
name: 'roleIds',
label: 'Rollen',
type: 'multiselect' as const,
required: true,
options: roleOptions as AttributeDefinition['options'],
},
{
name: 'enabled',
label: 'Aktiv',
type: 'checkbox' as const,
required: false,
},
],
[roleOptions]
);
const tabs = [
{
id: 'users',
label: 'Benutzer',
content: (
<div className={modalStyles.tabContent}>
{loading ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Benutzer...</span>
</div>
) : (
<PermissionMatrix
users={users}
roles={roles}
onEditUser={handleEditUser}
onRemoveUser={handleRemoveUser}
onAddUser={() => setShowAddModal(true)}
/>
)}
</div>
),
},
{
id: 'roles',
label: 'Rollen',
content: (
<div className={modalStyles.tabContent}>
<p className={modalStyles.rolesIntro}>
Rollen werden von der Feature-Vorlage übernommen. Mit Synchronisieren können Sie fehlende Rollen nachziehen.
</p>
<ul className={modalStyles.rolesList}>
{roles.map((r) => (
<li key={r.id}>{r.roleLabel}</li>
))}
</ul>
<button
type="button"
className={styles.secondaryButton}
onClick={handleSyncRoles}
disabled={syncing || roles.length === 0}
>
<FaSync className={syncing ? 'spinning' : ''} /> Rollen synchronisieren
</button>
</div>
),
},
{
id: 'settings',
label: 'Einstellungen',
content: (
<div className={modalStyles.tabContent}>
<FormGeneratorForm
attributes={[
{ name: 'label', type: 'string' as const, label: 'Bezeichnung', required: true, editable: true },
{ name: 'enabled', type: 'boolean' as const, label: 'Aktiviert', required: false, editable: true },
]}
data={instance}
mode="edit"
onSubmit={handleUpdateInstance}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
onCancel={() => {}}
/>
</div>
),
},
];
return (
<div className={styles.modalOverlay} onClick={onClose}>
<div className={`${styles.modal} ${modalStyles.modal}`} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<div>
<h2 className={styles.modalTitle}>{instance.label}</h2>
<p className={modalStyles.subtitle}>
{mandateName} · {featureLabel}
</p>
</div>
<button type="button" className={styles.modalClose} onClick={onClose} aria-label="Schließen">
</button>
</div>
<div className={styles.modalContent}>
<Tabs tabs={tabs} defaultTabId="users" />
</div>
</div>
{showAddModal && (
<div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Benutzer hinzufügen</h2>
<button type="button" className={styles.modalClose} onClick={() => setShowAddModal(false)}>
</button>
</div>
<div className={styles.modalContent}>
{availableUsers.length === 0 ? (
<p>Alle Mandanten-Benutzer haben bereits Zugriff.</p>
) : addUserFields.length < 2 || !roleOptions?.length ? (
<p>Laden...</p>
) : (
<FormGeneratorForm
attributes={addUserFields}
mode="create"
onSubmit={handleAddUser}
onCancel={() => setShowAddModal(false)}
submitButtonText="Hinzufügen"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
{editingUser && (
<div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Rollen: {editingUser.username}</h2>
<button type="button" className={styles.modalClose} onClick={() => setEditingUser(null)}>
</button>
</div>
<div className={styles.modalContent}>
<FormGeneratorForm
attributes={editRolesFields}
data={{ roleIds: editingUser.roleIds, enabled: editingUser.enabled }}
mode="edit"
onSubmit={handleUpdateRoles}
onCancel={() => setEditingUser(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
</div>
</div>
</div>
)}
</div>
);
};
export default InstanceDetailModal;

View file

@ -0,0 +1,400 @@
/**
* InstanceHierarchyView hierarchy levels and hover tooltip (glassmorph/glow)
*/
.hierarchyLoading {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1.5rem;
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
color: var(--text-secondary);
font-size: 0.9375rem;
}
.spinner {
width: 20px;
height: 20px;
border: 2px solid rgba(242, 88, 67, 0.3);
border-top-color: var(--primary-color, #f25843);
border-radius: 50%;
animation: hierarchySpin 0.8s linear infinite;
}
@keyframes hierarchySpin {
to {
transform: rotate(360deg);
}
}
.hierarchyRoot {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.levelMandateWrapper {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.mandateRow {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 1rem 1.25rem;
text-align: left;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 14px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
color: var(--text-primary);
font-size: 0.9375rem;
cursor: pointer;
transition: all 0.25s ease;
}
.mandateRow:hover {
background: rgba(242, 88, 67, 0.12);
border-color: rgba(242, 88, 67, 0.35);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12), 0 0 24px rgba(242, 88, 67, 0.22);
transform: translateY(-2px);
}
.mandateRow .mandateLabel {
flex: 1;
font-weight: 600;
color: var(--primary-color, #f25843);
}
.mandateRow .mandateMeta {
font-size: 0.8125rem;
color: var(--text-secondary);
letter-spacing: 0.02em;
}
.levelMandate {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 1.25rem;
margin-bottom: 0.5rem;
background: rgba(242, 88, 67, 0.12);
backdrop-filter: blur(10px);
border: 1px solid rgba(242, 88, 67, 0.25);
border-radius: 12px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.08);
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease;
}
.levelMandate:hover {
transform: translateY(-2px);
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.12), 0 0 24px rgba(242, 88, 67, 0.22);
border-color: rgba(242, 88, 67, 0.4);
}
.mandateIcon {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--primary-color, #f25843);
box-shadow: 0 0 10px rgba(242, 88, 67, 0.6);
flex-shrink: 0;
}
.mandateContent {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.mandateLabel {
font-weight: 600;
font-size: 1.0625rem;
color: var(--primary-color, #f25843);
}
.mandateMeta {
font-size: 0.75rem;
color: var(--text-secondary);
letter-spacing: 0.02em;
}
.levelFeature {
margin-left: 1rem;
margin-bottom: 1rem;
padding-left: 1rem;
border-left: 2px solid rgba(255, 255, 255, 0.12);
}
.featureHeader {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
transition: background 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
border: 1px solid transparent;
}
.featureHeader:hover {
background: rgba(255, 255, 255, 0.06);
box-shadow: 0 0 14px rgba(242, 88, 67, 0.12);
border-color: rgba(255, 255, 255, 0.08);
}
.featureLabel {
font-weight: 600;
font-size: 0.9375rem;
color: var(--text-primary);
}
.featureCount {
font-size: 0.75rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.levelInstance {
margin-bottom: 0.25rem;
}
.instanceRowContainer {
display: flex;
align-items: center;
gap: 0.5rem;
margin-left: 0.5rem;
}
.instanceRow {
display: flex;
align-items: center;
gap: 0.5rem;
flex: 1;
min-width: 0;
padding: 0.6rem 0.75rem;
text-align: left;
background: rgba(255, 255, 255, 0.06);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.25s ease;
}
.manageUsersBtn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
font-size: 0.75rem;
background: rgba(242, 88, 67, 0.12);
color: var(--primary-color, #f25843);
border: 1px solid rgba(242, 88, 67, 0.3);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
}
.manageUsersBtn:hover {
background: rgba(242, 88, 67, 0.22);
box-shadow: 0 0 12px rgba(242, 88, 67, 0.25);
}
.instanceRow:hover {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(242, 88, 67, 0.25);
box-shadow: 0 0 14px rgba(242, 88, 67, 0.15);
transform: translateY(-1px);
}
.instanceChevron {
display: flex;
align-items: center;
font-size: 0.7rem;
color: var(--text-secondary);
}
.instanceLabel {
flex: 1;
font-weight: 500;
}
.instanceUserCount {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: var(--text-secondary);
}
.levelUsers {
margin-left: 1.5rem;
margin-top: 0.35rem;
margin-bottom: 0.75rem;
padding: 0.5rem 0;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.noUsers {
font-size: 0.8125rem;
color: var(--text-secondary);
padding: 0.5rem 0.75rem;
}
.linkButton {
background: none;
border: none;
padding: 0;
font-size: inherit;
color: var(--primary-color, #f25843);
cursor: pointer;
text-decoration: underline;
margin-left: 0.25rem;
}
.linkButton:hover {
text-decoration: none;
}
.userRowWrapper {
position: relative;
padding: 0.4rem 0.75rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.userRowWrapper:hover {
border-color: rgba(255, 255, 255, 0.15);
box-shadow: 0 0 12px rgba(242, 88, 67, 0.1);
transform: translateY(-1px);
}
.userRowWrapper:hover .tooltipBubble {
opacity: 1;
visibility: visible;
}
.userRow {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
font-size: 0.8125rem;
}
.userName {
font-weight: 500;
color: var(--text-primary);
min-width: 120px;
}
.userRoles {
flex: 1;
font-size: 0.75rem;
color: var(--text-secondary);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.userStatusActive {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
border-radius: 4px;
background: rgba(56, 142, 60, 0.2);
color: #388e3c;
border: 1px solid rgba(56, 142, 60, 0.35);
}
.userStatusInactive {
font-size: 0.7rem;
padding: 0.15rem 0.4rem;
border-radius: 4px;
background: rgba(158, 158, 158, 0.15);
color: var(--text-secondary);
border: 1px solid rgba(158, 158, 158, 0.25);
}
.tooltipBubble {
position: absolute;
left: 100%;
top: 50%;
transform: translate(8px, -50%);
min-width: 200px;
max-width: 320px;
padding: 0.75rem 1rem;
background: rgba(20, 20, 24, 0.95);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3), 0 0 16px rgba(242, 88, 67, 0.15);
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
z-index: 100;
pointer-events: none;
}
.tooltipTitle {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--primary-color, #f25843);
margin-bottom: 0.35rem;
}
.tooltipEmail {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.35rem;
word-break: break-all;
}
.tooltipRoles {
font-size: 0.8125rem;
color: var(--text-primary);
line-height: 1.4;
word-break: break-word;
margin-bottom: 0.25rem;
}
.tooltipStatus {
font-size: 0.75rem;
color: var(--text-secondary);
}
:global(.dark-theme) .mandateRow,
:global(.dark-theme) .levelMandate,
:global(.dark-theme) .instanceRow,
:global(.dark-theme) .userRowWrapper {
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.08);
}
:global(.dark-theme) .mandateRow:hover {
background: rgba(242, 88, 67, 0.18);
border-color: rgba(242, 88, 67, 0.4);
}
:global(.dark-theme) .tooltipBubble {
background: rgba(10, 10, 14, 0.97);
border-color: rgba(255, 255, 255, 0.12);
}

View file

@ -0,0 +1,307 @@
/**
* InstanceHierarchyView
*
* Visual hierarchy: Mandanten (expandable) Feature Instanz (expandable) User.
* Hover over a user shows tooltip with Berechtigungen (roleLabels), E-Mail, and Aktiv/Inaktiv.
*/
import React, { useState, useMemo } from 'react';
import { FaChevronDown, FaChevronRight, FaUsers } from 'react-icons/fa';
import type { Feature } from '../../hooks/useFeatureAccess';
import type { FeatureAccessUser } from '../../hooks/useFeatureAccess';
import type { InstanceWithStats } from './AccessManagementHub';
import type { Mandate } from '../../hooks/useUserMandates';
import hubStyles from './AccessManagementHub.module.css';
import hierarchyStyles from './InstanceHierarchyView.module.css';
export interface InstanceHierarchyViewProps {
mandates: Mandate[];
getMandateName: (mandate: Mandate) => string;
instancesByMandate: Record<string, InstanceWithStats[]>;
instanceUsersMap: Record<string, FeatureAccessUser[]>;
features: Feature[];
getFeatureLabel: (feature: Feature) => string;
loading?: boolean;
onOpenDetail: (instance: InstanceWithStats, mandateId: string) => void;
}
function getFeatureLabelSafe(
features: Feature[],
featureCode: string,
getFeatureLabel: (f: Feature) => string
): string {
const f = features.find((x) => x.code === featureCode);
return f ? getFeatureLabel(f) : featureCode;
}
interface MandateContentProps {
mandateId: string;
mandateName: string;
instances: InstanceWithStats[];
instanceUsersMap: Record<string, FeatureAccessUser[]>;
features: Feature[];
getFeatureLabel: (f: Feature) => string;
onOpenDetail: (instance: InstanceWithStats, mandateId: string) => void;
}
function MandateContent({
mandateId,
mandateName,
instances,
instanceUsersMap,
features,
getFeatureLabel,
onOpenDetail,
}: MandateContentProps) {
const [expandedInstanceIds, setExpandedInstanceIds] = useState<Set<string>>(new Set());
const byFeature = useMemo(() => {
const map: Record<string, InstanceWithStats[]> = {};
instances.forEach((inst) => {
if (!map[inst.featureCode]) map[inst.featureCode] = [];
map[inst.featureCode].push(inst);
});
return map;
}, [instances]);
const mandateMeta = useMemo(() => {
const featureCount = Object.keys(byFeature).length;
const instanceCount = instances.length;
return { featureCount, instanceCount };
}, [byFeature, instances.length]);
const toggleInstance = (instanceId: string) => {
setExpandedInstanceIds((prev) => {
const next = new Set(prev);
if (next.has(instanceId)) next.delete(instanceId);
else next.add(instanceId);
return next;
});
};
return (
<div className={hierarchyStyles.mandateContentInner}>
<div className={hierarchyStyles.levelMandate}>
<span className={hierarchyStyles.mandateIcon} />
<div className={hierarchyStyles.mandateContent}>
<span className={hierarchyStyles.mandateLabel}>{mandateName}</span>
<span className={hierarchyStyles.mandateMeta}>
{mandateMeta.featureCount} Feature{mandateMeta.featureCount !== 1 ? 's' : ''} · {mandateMeta.instanceCount} Instanz{mandateMeta.instanceCount !== 1 ? 'en' : ''}
</span>
</div>
</div>
{Object.entries(byFeature).map(([featureCode, featureInstances]) => {
const featureUserCount = featureInstances.reduce(
(sum, inst) => sum + (instanceUsersMap[inst.id]?.length ?? 0),
0
);
return (
<div key={featureCode} className={hierarchyStyles.levelFeature}>
<div className={hierarchyStyles.featureHeader}>
<span className={hierarchyStyles.featureLabel}>
{getFeatureLabelSafe(features, featureCode, getFeatureLabel)}
</span>
<span className={hierarchyStyles.featureCount}>
{featureInstances.length} Instanz{featureInstances.length !== 1 ? 'en' : ''}
{featureUserCount > 0 && ` · ${featureUserCount} Benutzer`}
</span>
</div>
{featureInstances.map((inst) => {
const isExpanded = expandedInstanceIds.has(inst.id);
const users = instanceUsersMap[inst.id] ?? [];
return (
<div key={inst.id} className={hierarchyStyles.levelInstance}>
<div className={hierarchyStyles.instanceRowContainer}>
<button
type="button"
className={hierarchyStyles.instanceRow}
onClick={() => toggleInstance(inst.id)}
aria-expanded={isExpanded}
>
<span className={hierarchyStyles.instanceChevron}>
{isExpanded ? <FaChevronDown /> : <FaChevronRight />}
</span>
<span className={hierarchyStyles.instanceLabel}>{inst.label}</span>
<span
className={`${hubStyles.instanceBadge} ${inst.enabled ? hubStyles.badgeActive : hubStyles.badgeInactive}`}
>
{inst.enabled ? 'Aktiv' : 'Inaktiv'}
</span>
<span className={hierarchyStyles.instanceUserCount}>
<FaUsers /> {users.length}
</span>
</button>
<button
type="button"
className={hierarchyStyles.manageUsersBtn}
onClick={(e) => {
e.stopPropagation();
onOpenDetail(inst, mandateId);
}}
title="Benutzer verwalten"
>
<FaUsers /> Benutzer verwalten
</button>
</div>
{isExpanded && (
<div className={hierarchyStyles.levelUsers}>
{users.length === 0 ? (
<div className={hierarchyStyles.noUsers}>
Keine Benutzer zugeordnet.{' '}
<button
type="button"
className={hierarchyStyles.linkButton}
onClick={() => onOpenDetail(inst, mandateId)}
>
Benutzer verwalten
</button>
</div>
) : (
users.map((u) => <UserRow key={u.id} user={u} />)
)}
</div>
)}
</div>
);
})}
</div>
);
})}
</div>
);
}
export const InstanceHierarchyView: React.FC<InstanceHierarchyViewProps> = ({
mandates,
getMandateName,
instancesByMandate,
instanceUsersMap,
features,
getFeatureLabel,
loading,
onOpenDetail,
}) => {
const [expandedMandateIds, setExpandedMandateIds] = useState<Set<string>>(new Set());
const toggleMandate = (mandateId: string) => {
setExpandedMandateIds((prev) => {
const next = new Set(prev);
if (next.has(mandateId)) next.delete(mandateId);
else next.add(mandateId);
return next;
});
};
if (loading) {
return (
<section className={hubStyles.section}>
<div className={hierarchyStyles.hierarchyLoading}>
<span className={hierarchyStyles.spinner} />
<span>Lade Hierarchie und Benutzer...</span>
</div>
</section>
);
}
if (mandates.length === 0) {
return (
<section className={hubStyles.section}>
<h2 className={hubStyles.sectionTitle}>Hierarchie</h2>
<div className={hierarchyStyles.emptyHierarchy}>
Keine Mandanten vorhanden. Legen Sie unter &quot;Mandanten verwalten&quot; einen Mandanten an.
</div>
</section>
);
}
return (
<section className={hubStyles.section}>
<h2 className={hubStyles.sectionTitle}>Hierarchie</h2>
<div className={hierarchyStyles.hierarchyRoot}>
{mandates.map((mandate) => {
const mandateId = mandate.id;
const instances = instancesByMandate[mandateId] ?? [];
const isExpanded = expandedMandateIds.has(mandateId);
const mandateName = getMandateName(mandate);
const byFeat: Record<string, InstanceWithStats[]> = {};
instances.forEach((inst) => {
if (!byFeat[inst.featureCode]) byFeat[inst.featureCode] = [];
byFeat[inst.featureCode].push(inst);
});
const featureCount = Object.keys(byFeat).length;
return (
<div key={mandateId} className={hierarchyStyles.levelMandateWrapper}>
<button
type="button"
className={hierarchyStyles.mandateRow}
onClick={() => toggleMandate(mandateId)}
aria-expanded={isExpanded}
>
<span className={hierarchyStyles.instanceChevron}>
{isExpanded ? <FaChevronDown /> : <FaChevronRight />}
</span>
<span className={hierarchyStyles.mandateLabel}>{mandateName}</span>
<span className={hierarchyStyles.mandateMeta}>
{featureCount} Feature{featureCount !== 1 ? 's' : ''} · {instances.length} Instanz{instances.length !== 1 ? 'en' : ''}
</span>
</button>
{isExpanded && (
<MandateContent
mandateId={mandateId}
mandateName={mandateName}
instances={instances}
instanceUsersMap={instanceUsersMap}
features={features}
getFeatureLabel={getFeatureLabel}
onOpenDetail={onOpenDetail}
/>
)}
</div>
);
})}
</div>
</section>
);
};
interface UserRowProps {
user: FeatureAccessUser;
}
function UserRow({ user }: UserRowProps) {
const displayName = user.fullName?.trim() || user.username || user.userId;
const rolesText =
user.roleLabels && user.roleLabels.length > 0
? user.roleLabels.join(', ')
: 'Keine Rollen';
const statusText = user.enabled ? 'Aktiv' : 'Inaktiv';
return (
<div className={hierarchyStyles.userRowWrapper}>
<div className={hierarchyStyles.userRow}>
<span className={hierarchyStyles.userName}>{displayName}</span>
<span className={hierarchyStyles.userRoles}>{rolesText}</span>
<span
className={user.enabled ? hierarchyStyles.userStatusActive : hierarchyStyles.userStatusInactive}
>
{statusText}
</span>
</div>
<div className={hierarchyStyles.tooltipBubble} role="tooltip">
<div className={hierarchyStyles.tooltipTitle}>Berechtigungen</div>
{user.email && (
<div className={hierarchyStyles.tooltipEmail}>{user.email}</div>
)}
<div className={hierarchyStyles.tooltipRoles}>{rolesText}</div>
<div className={hierarchyStyles.tooltipStatus}>Zugang: {statusText}</div>
</div>
</div>
);
}
export default InstanceHierarchyView;

View file

@ -0,0 +1,162 @@
/**
* PermissionMatrix glassmorphism and glow for active cells
*/
.wrapper {
display: flex;
flex-direction: column;
gap: 1rem;
}
.tableWrap {
overflow-x: auto;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(8px);
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.table th,
.table td {
padding: 0.6rem 0.75rem;
text-align: left;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.table thead th {
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
font-size: 0.7rem;
letter-spacing: 0.05em;
background: rgba(0, 0, 0, 0.06);
}
.table tbody tr:hover {
background: rgba(255, 255, 255, 0.04);
}
.cellUser {
min-width: 160px;
}
.userName {
display: block;
font-weight: 500;
color: var(--text-primary);
}
.userEmail {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
}
.cellRole {
min-width: 70px;
text-align: center;
}
.cellActive {
min-width: 56px;
text-align: center;
}
.cellActions {
min-width: 90px;
white-space: nowrap;
}
.cellEmpty {
text-align: center;
color: var(--text-secondary);
padding: 1.5rem !important;
}
.badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.75rem;
padding: 0.2rem 0.4rem;
border-radius: 6px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-secondary);
font-size: 0.8rem;
}
.badgeActive {
background: linear-gradient(
135deg,
rgba(242, 88, 67, 0.25),
rgba(242, 88, 67, 0.12)
);
border-color: rgba(242, 88, 67, 0.4);
color: var(--primary-color, #f25843);
box-shadow: 0 0 10px rgba(242, 88, 67, 0.2);
}
.actionBtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
margin-right: 4px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s ease;
}
.actionBtn:hover:not(:disabled) {
background: rgba(242, 88, 67, 0.2);
border-color: rgba(242, 88, 67, 0.4);
color: var(--primary-color, #f25843);
box-shadow: 0 0 8px rgba(242, 88, 67, 0.25);
}
.actionBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.actionBtnDanger:hover:not(:disabled) {
background: rgba(220, 53, 69, 0.2);
border-color: rgba(220, 53, 69, 0.4);
color: #dc3545;
box-shadow: 0 0 8px rgba(220, 53, 69, 0.25);
}
.footer {
display: flex;
justify-content: flex-start;
}
.empty {
padding: 1.5rem;
text-align: center;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.04);
border-radius: 12px;
border: 1px dashed rgba(255, 255, 255, 0.15);
}
:global(.dark-theme) .tableWrap {
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.08);
}
:global(.dark-theme) .table thead th {
background: rgba(0, 0, 0, 0.2);
}

View file

@ -0,0 +1,142 @@
/**
* PermissionMatrix
*
* User × Role matrix with inline toggles and edit/remove actions.
*/
import React, { useState } from 'react';
import { FaEdit, FaTrash } from 'react-icons/fa';
import type { FeatureAccessUser } from '../../hooks/useFeatureAccess';
import type { FeatureInstanceRole } from '../../hooks/useFeatureAccess';
import styles from './Admin.module.css';
import matrixStyles from './PermissionMatrix.module.css';
export interface PermissionMatrixProps {
users: FeatureAccessUser[];
roles: FeatureInstanceRole[];
onEditUser: (user: FeatureAccessUser) => void;
onRemoveUser: (user: FeatureAccessUser) => void;
onAddUser: () => void;
disabled?: boolean;
}
export const PermissionMatrix: React.FC<PermissionMatrixProps> = ({
users,
roles,
onEditUser,
onRemoveUser,
onAddUser,
disabled = false,
}) => {
const [removingId, setRemovingId] = useState<string | null>(null);
const handleRemove = (user: FeatureAccessUser) => {
if (removingId) return;
if (window.confirm(`"${user.username}" aus dieser Instanz entfernen?`)) {
setRemovingId(user.userId);
onRemoveUser(user);
setRemovingId(null);
}
};
if (roles.length === 0) {
return (
<div className={matrixStyles.empty}>
<p>Keine Rollen in dieser Instanz. Bitte zuerst Rollen synchronisieren.</p>
</div>
);
}
return (
<div className={matrixStyles.wrapper}>
<div className={matrixStyles.tableWrap}>
<table className={matrixStyles.table}>
<thead>
<tr>
<th className={matrixStyles.cellUser}>Benutzer</th>
{roles.map((r) => (
<th key={r.id} className={matrixStyles.cellRole}>
{r.roleLabel}
</th>
))}
<th className={matrixStyles.cellActive}>Aktiv</th>
<th className={matrixStyles.cellActions}>Aktionen</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td colSpan={roles.length + 3} className={matrixStyles.cellEmpty}>
Keine Benutzer zugewiesen.
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id}>
<td className={matrixStyles.cellUser}>
<span className={matrixStyles.userName}>{user.username}</span>
{user.email && (
<span className={matrixStyles.userEmail}>{user.email}</span>
)}
</td>
{roles.map((role) => {
const hasRole = user.roleIds?.includes(role.id) ?? false;
return (
<td key={role.id} className={matrixStyles.cellRole}>
<span
className={`${matrixStyles.badge} ${hasRole ? matrixStyles.badgeActive : ''}`}
title={hasRole ? role.roleLabel : ''}
>
{hasRole ? '✓' : '—'}
</span>
</td>
);
})}
<td className={matrixStyles.cellActive}>
<span
className={`${matrixStyles.badge} ${user.enabled ? matrixStyles.badgeActive : ''}`}
>
{user.enabled ? '✓' : '—'}
</span>
</td>
<td className={matrixStyles.cellActions}>
<button
type="button"
className={matrixStyles.actionBtn}
onClick={() => onEditUser(user)}
disabled={disabled}
title="Rollen bearbeiten"
>
<FaEdit />
</button>
<button
type="button"
className={`${matrixStyles.actionBtn} ${matrixStyles.actionBtnDanger}`}
onClick={() => handleRemove(user)}
disabled={disabled || removingId === user.userId}
title="Aus Instanz entfernen"
>
<FaTrash />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className={matrixStyles.footer}>
<button
type="button"
className={styles.primaryButton}
onClick={onAddUser}
disabled={disabled}
>
+ Benutzer hinzufügen
</button>
</div>
</div>
);
};
export default PermissionMatrix;

View file

@ -4,6 +4,7 @@
* Export all admin pages for easy importing * Export all admin pages for easy importing
*/ */
export { AccessManagementHub } from './AccessManagementHub';
export { AdminMandatesPage } from './AdminMandatesPage'; export { AdminMandatesPage } from './AdminMandatesPage';
export { AdminUsersPage } from './AdminUsersPage'; export { AdminUsersPage } from './AdminUsersPage';
export { AdminUserMandatesPage } from './AdminUserMandatesPage'; export { AdminUserMandatesPage } from './AdminUserMandatesPage';

View file

@ -0,0 +1,113 @@
/**
* RealEstatePekView
*
* PEK-UI für eine Real-Estate-Instanz: Karte, Adresseingabe, optional Command-Eingabe und Nachrichten.
* Wird als Dashboard-View gerendert, wenn der Benutzer auf "PEK" in der Sidebar klickt.
*/
import React from 'react';
import { IoMdSend } from 'react-icons/io';
import { PekProvider, usePekContext } from '../../../contexts/PekContext';
import { Button, TextField } from '../../../components/UiComponents';
import PekLocationInput from './pek/PekLocationInput';
import PekMapView from './pek/PekMapView';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from '../trustee/TrusteeViews.module.css';
function RealEstatePekViewContent() {
const {
commandInput,
setCommandInput,
processCommand,
isProcessingCommand,
commandResults
} = usePekContext();
const { t } = useLanguage();
const onSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (commandInput.trim()) {
processCommand(commandInput.trim());
}
};
return (
<div className={styles.dashboardView}>
<p className={styles.muted} style={{ marginBottom: '1rem' }}>
{t('projects.description_text')}
</p>
<PekLocationInput />
<PekMapView />
{/* Optional: Command input and results */}
<section style={{ marginTop: '2rem' }}>
<form onSubmit={onSubmit} className={styles.form} style={{ maxWidth: 600 }}>
<div className={styles.formField}>
<label htmlFor="pek-command">
{t('projects.command.placeholder')}
</label>
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'flex-end' }}>
<div style={{ flex: 1 }}>
<TextField
id="pek-command"
value={commandInput}
onChange={setCommandInput}
placeholder={t('projects.command.placeholder')}
disabled={isProcessingCommand}
size="md"
type="text"
name="command"
/>
</div>
<Button
type="submit"
variant="primary"
size="md"
icon={IoMdSend}
disabled={!commandInput.trim() || isProcessingCommand}
loading={isProcessingCommand}
>
Senden
</Button>
</div>
</div>
</form>
{commandResults.length > 0 && (
<div style={{ marginTop: '1rem', maxWidth: 800 }}>
<h3 style={{ fontSize: '1rem', marginBottom: '0.5rem' }}>Antworten</h3>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{commandResults.map((msg: any) => (
<div
key={msg.id}
style={{
padding: '0.75rem 1rem',
background: 'var(--surface-color, #f8f9fa)',
border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8,
fontSize: '0.875rem',
whiteSpace: 'pre-wrap'
}}
>
<strong>{msg.role === 'user' ? 'Sie' : 'Assistent'}:</strong>{' '}
{typeof msg.message === 'string' ? msg.message : JSON.stringify(msg.message)}
</div>
))}
</div>
</div>
)}
</section>
</div>
);
}
export const RealEstatePekView: React.FC = () => {
return (
<PekProvider>
<RealEstatePekViewContent />
</PekProvider>
);
};
export default RealEstatePekView;

View file

@ -0,0 +1,63 @@
.locationInputContainer {
width: 100%;
margin-bottom: 1.5rem;
}
.fieldsRow {
display: flex;
gap: 1rem;
align-items: flex-end;
}
.fieldWrapper {
flex: 1;
}
.buttonsWrapper {
display: flex;
flex-direction: row;
gap: 0.5rem;
min-width: 150px;
}
.searchButton {
white-space: nowrap;
}
.locationButton {
white-space: nowrap;
}
@media (max-width: 1024px) {
.fieldsRow {
flex-wrap: wrap;
}
.buttonsWrapper {
width: 100%;
}
.fieldWrapper {
min-width: calc(50% - 0.5rem);
}
}
@media (max-width: 768px) {
.fieldsRow {
flex-direction: column;
}
.fieldWrapper {
width: 100%;
min-width: 100%;
}
.buttonsWrapper {
width: 100%;
}
.searchButton,
.locationButton {
flex: 1;
}
}

View file

@ -0,0 +1,80 @@
import React from 'react';
import { TextField, Button } from '../../../../components/UiComponents';
import { FaLocationArrow } from 'react-icons/fa';
import { IoMdSend } from 'react-icons/io';
import { usePekContext } from '../../../../contexts/PekContext';
import styles from './PekLocationInput.module.css';
const PekLocationInput: React.FC = () => {
const {
adresse,
setAdresse,
buildLocationString,
useCurrentLocation,
isGettingLocation,
searchParcel,
isSearchingParcel
} = usePekContext();
const handleSearch = async () => {
const locationString = buildLocationString();
if (locationString.trim()) {
await searchParcel(locationString.trim(), true);
}
};
const handleUseCurrentLocation = async () => {
await useCurrentLocation();
};
return (
<div className={styles.locationInputContainer}>
<div className={styles.fieldsRow}>
<div className={styles.fieldWrapper}>
<TextField
value={adresse}
onChange={setAdresse}
placeholder="z.B. Bundesplatz 3"
label="Adresse oder Parzelle"
disabled={isGettingLocation || isSearchingParcel}
size="md"
type="text"
name="adresse"
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSearch();
}
}}
/>
</div>
<div className={styles.buttonsWrapper}>
<Button
variant="primary"
size="md"
icon={IoMdSend}
onClick={handleSearch}
disabled={!buildLocationString().trim() || isGettingLocation || isSearchingParcel}
loading={isSearchingParcel}
className={styles.searchButton}
>
Suchen
</Button>
<Button
variant="secondary"
size="md"
icon={FaLocationArrow}
onClick={handleUseCurrentLocation}
disabled={isGettingLocation || isSearchingParcel}
loading={isGettingLocation}
className={styles.locationButton}
>
Meine Position
</Button>
</div>
</div>
</div>
);
};
export default PekLocationInput;

View file

@ -0,0 +1,58 @@
import React from 'react';
import { MapView, ParcelInfoPanel } from '../../../../components/UiComponents';
import { usePekContext } from '../../../../contexts/PekContext';
const PekMapView: React.FC = () => {
const {
mapCenter,
mapZoomBounds,
parcelGeometries,
handleMapClick,
handleParcelClick,
selectedParcels,
removeParcel,
isPanelOpen,
setIsPanelOpen
} = usePekContext();
// Aggregate all adjacent parcels from all selected parcels
const allAdjacentParcels = React.useMemo(() => {
const adjacentSet = new Map<string, any>();
selectedParcels.forEach((parcel) => {
if (parcel.adjacent_parcels) {
parcel.adjacent_parcels.forEach((adj: { id: string }) => {
if (!adjacentSet.has(adj.id)) {
adjacentSet.set(adj.id, adj);
}
});
}
});
return Array.from(adjacentSet.values());
}, [selectedParcels]);
return (
<>
<div style={{ marginBottom: '1.5rem' }}>
<MapView
parcels={parcelGeometries}
center={mapCenter || undefined}
zoomBounds={mapZoomBounds || undefined}
onMapClick={handleMapClick}
onParcelClick={handleParcelClick}
height="600px"
emptyMessage="Klicken Sie auf die Karte, um einen Standort auszuwählen, oder suchen Sie nach einer Adresse oben."
/>
</div>
<ParcelInfoPanel
isOpen={isPanelOpen}
onClose={() => setIsPanelOpen(false)}
parcels={selectedParcels}
onRemoveParcel={removeParcel}
adjacentParcels={allAdjacentParcels}
/>
</>
);
};
export default PekMapView;