Admin UX: Zugriffsverwaltung-Hub, InstanceDetailModal, PermissionMatrix, Wizard, Sidebar-Links, Hierarchie-Styling (mandateRow Glow/Glassmorph)
This commit is contained in:
parent
1342bdbcca
commit
83530a44bd
21 changed files with 4116 additions and 4 deletions
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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 />,
|
||||||
|
|
|
||||||
76
src/contexts/PekContext.tsx
Normal file
76
src/contexts/PekContext.tsx
Normal 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
1004
src/hooks/usePek.ts
Normal file
File diff suppressed because it is too large
Load diff
312
src/pages/admin/AccessManagementHub.module.css
Normal file
312
src/pages/admin/AccessManagementHub.module.css
Normal 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);
|
||||||
|
}
|
||||||
566
src/pages/admin/AccessManagementHub.tsx
Normal file
566
src/pages/admin/AccessManagementHub.tsx
Normal 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;
|
||||||
|
|
@ -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 */}
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
|
|
|
||||||
103
src/pages/admin/FeatureInstanceWizard.module.css
Normal file
103
src/pages/admin/FeatureInstanceWizard.module.css
Normal 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);
|
||||||
|
}
|
||||||
294
src/pages/admin/FeatureInstanceWizard.tsx
Normal file
294
src/pages/admin/FeatureInstanceWizard.tsx
Normal 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;
|
||||||
31
src/pages/admin/InstanceDetailModal.module.css
Normal file
31
src/pages/admin/InstanceDetailModal.module.css
Normal 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;
|
||||||
|
}
|
||||||
367
src/pages/admin/InstanceDetailModal.tsx
Normal file
367
src/pages/admin/InstanceDetailModal.tsx
Normal 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;
|
||||||
400
src/pages/admin/InstanceHierarchyView.module.css
Normal file
400
src/pages/admin/InstanceHierarchyView.module.css
Normal 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);
|
||||||
|
}
|
||||||
307
src/pages/admin/InstanceHierarchyView.tsx
Normal file
307
src/pages/admin/InstanceHierarchyView.tsx
Normal 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 "Mandanten verwalten" 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;
|
||||||
162
src/pages/admin/PermissionMatrix.module.css
Normal file
162
src/pages/admin/PermissionMatrix.module.css
Normal 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);
|
||||||
|
}
|
||||||
142
src/pages/admin/PermissionMatrix.tsx
Normal file
142
src/pages/admin/PermissionMatrix.tsx
Normal 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;
|
||||||
|
|
@ -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';
|
||||||
|
|
|
||||||
113
src/pages/views/realestate/RealEstatePekView.tsx
Normal file
113
src/pages/views/realestate/RealEstatePekView.tsx
Normal 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;
|
||||||
63
src/pages/views/realestate/pek/PekLocationInput.module.css
Normal file
63
src/pages/views/realestate/pek/PekLocationInput.module.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
80
src/pages/views/realestate/pek/PekLocationInput.tsx
Normal file
80
src/pages/views/realestate/pek/PekLocationInput.tsx
Normal 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;
|
||||||
58
src/pages/views/realestate/pek/PekMapView.tsx
Normal file
58
src/pages/views/realestate/pek/PekMapView.tsx
Normal 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;
|
||||||
Loading…
Reference in a new issue