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)
|
||||
*/
|
||||
|
||||
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 global CSS reset first
|
||||
|
|
@ -41,7 +41,7 @@ import { DashboardPage } from './pages/Dashboard';
|
|||
import { SettingsPage } from './pages/Settings';
|
||||
import { GDPRPage } from './pages/GDPR';
|
||||
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)
|
||||
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
|
||||
|
|
@ -128,6 +128,7 @@ function App() {
|
|||
{/* ============================================== */}
|
||||
{/* MIGRATE TO FEATURES (temporary) */}
|
||||
{/* ============================================== */}
|
||||
<Route path="chatbot" element={<Navigate to="/" replace />} />
|
||||
<Route path="pek" element={<PekPage />} />
|
||||
<Route path="speech" element={<SpeechPage />} />
|
||||
|
||||
|
|
@ -154,6 +155,8 @@ function App() {
|
|||
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
||||
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
|
||||
<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 */}
|
||||
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
||||
|
|
@ -163,6 +166,8 @@ function App() {
|
|||
{/* ADMIN ROUTES (nur SysAdmin) */}
|
||||
{/* ============================================== */}
|
||||
<Route path="admin">
|
||||
<Route index element={<Navigate to="/admin/access" replace />} />
|
||||
<Route path="access" element={<AccessManagementHub />} />
|
||||
<Route path="mandates" element={<AdminMandatesPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="user-mandates" element={<AdminUserMandatesPage />} />
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
|||
'page.system.speech': <FaMicrophone />,
|
||||
|
||||
// Admin pages
|
||||
'page.admin.access': <FaBuilding />,
|
||||
'page.admin.users': <FaUsers />,
|
||||
'page.admin.invitations': <FaEnvelopeOpenText />,
|
||||
'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 { useNavigate } from 'react-router-dom';
|
||||
import { useMandateRoles, type Role, type RoleCreate, type RoleUpdate, type PaginationParams } from '../../hooks/useMandateRoles';
|
||||
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
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 api from '../../api';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
export const AdminMandateRolesPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { showError, showWarning } = useToast();
|
||||
const {
|
||||
roles,
|
||||
|
|
@ -310,6 +312,22 @@ export const AdminMandateRolesPage: React.FC = () => {
|
|||
<h1 className={styles.pageTitle}>Rollen</h1>
|
||||
<p className={styles.pageSubtitle}>Verwalten Sie System-, globale und mandantenspezifische Rollen</p>
|
||||
</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>
|
||||
|
||||
{/* Mandate Selector and Filters */}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,11 @@
|
|||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useOrgUsers, useUserOperations } from '../../hooks/useUsers';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
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';
|
||||
|
||||
interface User {
|
||||
|
|
@ -22,6 +23,7 @@ interface User {
|
|||
}
|
||||
|
||||
export const AdminUsersPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
// Use two hooks: one for data, one for operations
|
||||
const {
|
||||
data: users,
|
||||
|
|
@ -143,6 +145,13 @@ export const AdminUsersPage: React.FC = () => {
|
|||
<p className={styles.pageSubtitle}>Verwalten Sie alle Benutzer im System</p>
|
||||
</div>
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => navigate('/admin/invitations')}
|
||||
>
|
||||
<FaEnvelopeOpenText /> Einladungen
|
||||
</button>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
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 { AccessManagementHub } from './AccessManagementHub';
|
||||
export { AdminMandatesPage } from './AdminMandatesPage';
|
||||
export { AdminUsersPage } from './AdminUsersPage';
|
||||
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