diff --git a/src/App.tsx b/src/App.tsx index da7e872..c9da740 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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) */} {/* ============================================== */} + } /> } /> } /> @@ -154,6 +155,8 @@ function App() { } /> } /> } /> + } /> + } /> {/* Catch-all für unbekannte Sub-Pfade */} } /> @@ -163,6 +166,8 @@ function App() { {/* ADMIN ROUTES (nur SysAdmin) */} {/* ============================================== */} + } /> + } /> } /> } /> } /> diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index c3c2e9f..2368238 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -47,6 +47,7 @@ export const PAGE_ICONS: Record = { 'page.system.speech': , // Admin pages + 'page.admin.access': , 'page.admin.users': , 'page.admin.invitations': , 'page.admin.mandates': , diff --git a/src/contexts/PekContext.tsx b/src/contexts/PekContext.tsx new file mode 100644 index 0000000..fbd3e06 --- /dev/null +++ b/src/contexts/PekContext.tsx @@ -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; + isGettingLocation: boolean; + locationError: string | null; + + // Parcel search + selectedParcels: any[]; + searchParcel: (location: string, includeAdjacent?: boolean) => Promise; + 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; + handleParcelClick: (parcelId: string) => Promise; + + // Command processing + commandInput: string; + setCommandInput: (value: string) => void; + processCommand: (userInput: string) => Promise; + isProcessingCommand: boolean; + commandResults: any[]; + commandError: string | null; + + // Project management + currentProjekt: any; + createProjekt: (data: any) => Promise; + isCreatingProjekt: boolean; + addParcelToProjekt: (projektId: string, data: any) => Promise; + isAddingParcel: boolean; + projektError: string | null; + + // Panel state + isPanelOpen: boolean; + setIsPanelOpen: (open: boolean) => void; +} + +const PekContext = createContext(undefined); + +export const PekProvider: React.FC<{ children: ReactNode }> = ({ children }) => { + const pekData = usePek(); + + return ( + + {children} + + ); +}; + +export const usePekContext = (): PekContextType => { + const context = useContext(PekContext); + if (!context) { + throw new Error('usePekContext must be used within a PekProvider'); + } + return context; +}; + diff --git a/src/hooks/usePek.ts b/src/hooks/usePek.ts new file mode 100644 index 0000000..5330cae --- /dev/null +++ b/src/hooks/usePek.ts @@ -0,0 +1,1004 @@ +import { useState, useCallback, useEffect } from 'react'; +import api from '../api'; +import type { MapPoint, ParcelGeometry } from '../components/UiComponents/MapView'; +import { wgs84ToLV95 } from '../components/UiComponents/MapView/LV95Converter'; + +// Parcel search response interfaces +export interface ParcelSearchResponse { + parcel: { + id: string; + egrid?: string; + number?: string; + name?: string; + identnd?: string; + canton?: string; + municipality_code?: number; + municipality_name?: string; + address?: string; + perimeter?: { + closed: boolean; + punkte: Array<{ + koordinatensystem: string; + x: number; + y: number; + z: number | null; + }>; + }; + area_m2?: number; + centroid?: { x: number; y: number }; + geoportal_url?: string; + realestate_type?: string | null; + bauzone?: string | null; + zone?: Array | null; + }; + map_view: { + center: { x: number; y: number }; + zoom_bounds: { + min_x: number; + min_y: number; + max_x: number; + max_y: number; + }; + geometry_geojson: { + type: string; + geometry: { + type: string; + coordinates: number[][][]; + }; + properties: { + id: string; + egrid?: string; + number?: string; + }; + }; + }; + adjacent_parcels?: Array<{ + id: string; + egrid?: string; + number?: string; + perimeter?: { + closed: boolean; + punkte: Array<{ + koordinatensystem: string; + x: number; + y: number; + z: number | null; + }>; + }; + geometry_geojson?: { + type: string; + geometry: { + type: string; + coordinates: number[][][]; + }; + properties: { + id: string; + egrid?: string; + number?: string; + }; + }; + }>; + gemeinde?: { + id: string; + label: string; + plz: string; + }; + documents?: Array<{ + id: string; + label: string; + dokumentTyp: string; + dokumentReferenz: string; + quelle: string; + mimeType: string; + }>; +} + +// Command response interface +export interface CommandResponse { + success: boolean; + intent?: string; + entity?: string; + result?: any; + error?: string; +} + +// Project interfaces +export interface Projekt { + id: string; + mandateId?: string; + label: string; + statusProzess?: string; + perimeter?: any; + baulinie?: any; + parzellen?: any[]; + dokumente?: any[]; + kontextInformationen?: any[]; +} + +export interface CreateProjektResponse { + projekt: Projekt; + parzellen?: any[]; +} + +export interface AddParcelResponse { + projekt: Projekt; + parzelle: any; +} + +// Main PEK hook +export function usePek() { + // Location input state - separate fields + const [kanton, setKanton] = useState(''); + const [gemeinde, setGemeinde] = useState(''); + const [adresse, setAdresse] = useState(''); + const [isGettingLocation, setIsGettingLocation] = useState(false); + const [locationError, setLocationError] = useState(null); + + // Legacy locationInput for backward compatibility (combines fields) + const locationInput = [kanton, gemeinde, adresse].filter(Boolean).join(', '); + const setLocationInput = (value: string) => { + // Parse combined input if needed (for map clicks, etc.) + const parts = value.split(',').map(p => p.trim()); + if (parts.length >= 3) { + setKanton(parts[0]); + setGemeinde(parts[1]); + setAdresse(parts.slice(2).join(', ')); + } else { + setAdresse(value); + } + }; + + // Parcel search state + const [selectedParcels, setSelectedParcels] = useState([]); + const [isSearchingParcel, setIsSearchingParcel] = useState(false); + const [parcelSearchError, setParcelSearchError] = useState(null); + + // Map view state + const [mapCenter, setMapCenter] = useState(null); + const [mapZoomBounds, setMapZoomBounds] = useState<{ + min_x: number; + min_y: number; + max_x: number; + max_y: number; + } | null>(null); + const [parcelGeometries, setParcelGeometries] = useState([]); + + // Command processing state + const [commandInput, setCommandInput] = useState(''); + const [isProcessingCommand, setIsProcessingCommand] = useState(false); + const [commandResults, setCommandResults] = useState([]); + const [commandError, setCommandError] = useState(null); + + // Project state + const [currentProjekt, setCurrentProjekt] = useState(null); + const [isCreatingProjekt, setIsCreatingProjekt] = useState(false); + const [isAddingParcel, setIsAddingParcel] = useState(false); + const [projektError, setProjektError] = useState(null); + + // Panel state + const [isPanelOpen, setIsPanelOpen] = useState(false); + + // Update parcel geometries when selected parcels change + // Ensure all selected parcels are marked as selected and not as adjacent + useEffect(() => { + const selectedParcelIds = new Set(selectedParcels.map(p => p.parcel.id)); + + setParcelGeometries(prev => prev.map(geo => { + const isSelected = selectedParcelIds.has(geo.id); + // If parcel is selected, it should not be marked as adjacent + const isAdjacent = isSelected ? false : geo.isAdjacent; + return { ...geo, isSelected, isAdjacent }; + })); + }, [selectedParcels]); + + /** + * Get current geolocation and directly search for parcel + * Does not fill input fields, directly makes the request + */ + const useCurrentLocation = useCallback(async () => { + setIsGettingLocation(true); + setLocationError(null); + + try { + if (!navigator.geolocation) { + throw new Error('Geolocation wird von Ihrem Browser nicht unterstützt'); + } + + return new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition( + async (position) => { + try { + // Convert WGS84 to LV95 using the converter function + const lat = position.coords.latitude; + const lon = position.coords.longitude; + + const lv95 = wgs84ToLV95(lat, lon); + const locationString = `${Math.round(lv95.x)},${Math.round(lv95.y)}`; + + // Directly search for parcel without updating input fields + await searchParcel(locationString, true); + resolve(); + } catch (err: any) { + setLocationError(err.message || 'Fehler beim Konvertieren der Koordinaten'); + reject(err); + } + }, + (error) => { + let errorMessage = 'Fehler beim Abrufen der Position'; + switch (error.code) { + case error.PERMISSION_DENIED: + errorMessage = 'Zugriff auf Standort wurde verweigert'; + break; + case error.POSITION_UNAVAILABLE: + errorMessage = 'Standortinformationen nicht verfügbar'; + break; + case error.TIMEOUT: + errorMessage = 'Zeitüberschreitung beim Abrufen der Position'; + break; + } + setLocationError(errorMessage); + reject(new Error(errorMessage)); + }, + { + enableHighAccuracy: true, + timeout: 10000, + maximumAge: 0 + } + ); + }); + } catch (err: any) { + setLocationError(err.message || 'Fehler beim Abrufen der aktuellen Position'); + throw err; + } finally { + setIsGettingLocation(false); + } + }, []); + + /** + * Search for parcel by location (address or coordinates) + * Always includes adjacent parcels by default + */ + const searchParcel = useCallback(async (location: string, includeAdjacent: boolean = true) => { + if (!location.trim()) { + setParcelSearchError('Bitte geben Sie einen Standort ein'); + return; + } + + setIsSearchingParcel(true); + setParcelSearchError(null); + + try { + const response = await api.get('/api/realestate/parcel/search', { + params: { + location: location.trim(), + include_adjacent: includeAdjacent + } + }); + + const data: ParcelSearchResponse = response.data; + + // Debug logging + if (import.meta.env.DEV) { + console.log('📦 Parcel search response:', { + hasMapView: !!data.map_view, + hasGeometry: !!data.map_view?.geometry_geojson, + hasPerimeter: !!data.parcel.perimeter, + adjacentCount: data.adjacent_parcels?.length || 0 + }); + } + + // Add parcel to selected parcels array if not already selected + // Update geometries within the callback to have access to updated selectedParcels + setSelectedParcels(prev => { + const exists = prev.some(p => p.parcel.id === data.parcel.id); + if (exists) { + return prev; // Already selected, don't add again + } + + const updatedSelectedParcels = [...prev, data]; + const selectedParcelIds = new Set(updatedSelectedParcels.map(p => p.parcel.id)); + + // Update geometries + setParcelGeometries(currentGeometries => { + const geometryMap = new Map(); + + // Keep existing geometries + currentGeometries.forEach(geo => { + geometryMap.set(geo.id, geo); + }); + + // Update map center and zoom bounds + if (data.map_view) { + setMapCenter(data.map_view.center); + setMapZoomBounds(data.map_view.zoom_bounds); + + // Main parcel - use geometry_geojson if available, otherwise use perimeter.punkte + let mainParcelCoordinates: MapPoint[] = []; + + if (data.map_view.geometry_geojson?.geometry?.coordinates) { + const coords = data.map_view.geometry_geojson.geometry.coordinates[0]; + if (Array.isArray(coords)) { + mainParcelCoordinates = coords.map((coord: number[]) => ({ + x: coord[0], + y: coord[1] + })); + } + } else if (data.parcel.perimeter?.punkte) { + mainParcelCoordinates = data.parcel.perimeter.punkte.map((p) => ({ + x: p.x, + y: p.y + })); + } + + if (mainParcelCoordinates.length > 0) { + geometryMap.set(data.parcel.id, { + id: data.parcel.id, + egrid: data.parcel.egrid, + number: data.parcel.number, + coordinates: mainParcelCoordinates, + isSelected: true, + isAdjacent: false + }); + } + + // Add adjacent parcels, but skip if already selected + if (data.adjacent_parcels && includeAdjacent && data.adjacent_parcels.length > 0) { + data.adjacent_parcels.forEach((adjacent) => { + // Skip if this adjacent parcel is already selected + if (selectedParcelIds.has(adjacent.id)) { + // If it exists, mark as selected, not adjacent + const existingGeo = geometryMap.get(adjacent.id); + if (existingGeo) { + geometryMap.set(adjacent.id, { + ...existingGeo, + isSelected: true, + isAdjacent: false + }); + } + if (import.meta.env.DEV) { + console.log(`⏭️ Skipping adjacent parcel ${adjacent.id} - already selected`); + } + return; + } + + // Only add if not already in map + if (!geometryMap.has(adjacent.id)) { + let adjCoordinates: MapPoint[] = []; + + if (adjacent.geometry_geojson?.geometry?.coordinates) { + const coords = adjacent.geometry_geojson.geometry.coordinates[0]; + if (Array.isArray(coords) && coords.length > 0) { + adjCoordinates = coords.map((coord: number[]) => ({ + x: coord[0], + y: coord[1] + })); + } + } else if (adjacent.perimeter?.punkte) { + adjCoordinates = adjacent.perimeter.punkte.map((p) => ({ + x: p.x, + y: p.y + })); + } + + if (adjCoordinates.length >= 3) { + geometryMap.set(adjacent.id, { + id: adjacent.id, + egrid: adjacent.egrid, + number: adjacent.number, + coordinates: adjCoordinates, + isSelected: false, + isAdjacent: true + }); + } + } + }); + } + } else { + // If no map_view, still try to use parcel data + if (data.parcel.perimeter?.punkte) { + const coordinates = data.parcel.perimeter.punkte.map((p) => ({ + x: p.x, + y: p.y + })); + + geometryMap.set(data.parcel.id, { + id: data.parcel.id, + egrid: data.parcel.egrid, + number: data.parcel.number, + coordinates, + isSelected: true, + isAdjacent: false + }); + + if (data.parcel.centroid) { + setMapCenter(data.parcel.centroid); + } + } + } + + // Update all geometries: mark selected ones and unmark adjacent for selected ones + const updatedGeometries = Array.from(geometryMap.values()).map(geo => { + const isSelected = selectedParcelIds.has(geo.id); + return { + ...geo, + isSelected, + isAdjacent: isSelected ? false : geo.isAdjacent + }; + }); + + if (import.meta.env.DEV) { + console.log(`🗺️ Total geometries to display: ${updatedGeometries.length}`, { + selected: updatedGeometries.filter(g => g.isSelected).length, + adjacent: updatedGeometries.filter(g => g.isAdjacent).length + }); + } + + return updatedGeometries; + }); + + return updatedSelectedParcels; + }); + + // Open panel when parcel is found + setIsPanelOpen(true); + + return { success: true, data }; + } catch (err: any) { + const errorMessage = + err.response?.data?.detail || err.message || 'Fehler beim Suchen der Parzelle'; + setParcelSearchError(errorMessage); + return { success: false, error: errorMessage }; + } finally { + setIsSearchingParcel(false); + } + }, []); + + /** + * Handle map click - search for parcel at clicked coordinates + */ + const handleMapClick = useCallback( + async (point: MapPoint) => { + const locationString = `${point.x},${point.y}`; + setLocationInput(locationString); + await searchParcel(locationString, true); // Always include adjacent parcels + }, + [searchParcel] + ); + + /** + * Check if a parcel is selected + */ + const isParcelSelected = useCallback((parcelId: string): boolean => { + return selectedParcels.some(p => p.parcel.id === parcelId); + }, [selectedParcels]); + + /** + * Remove a parcel from selection + */ + const removeParcel = useCallback((parcelId: string) => { + setSelectedParcels(prev => prev.filter(p => p.parcel.id !== parcelId)); + // Update geometries to reflect deselection + setParcelGeometries(prev => prev.map(geo => + geo.id === parcelId ? { ...geo, isSelected: false } : geo + )); + }, []); + + /** + * Clear all selected parcels + */ + const clearSelectedParcels = useCallback(() => { + setSelectedParcels([]); + // Update geometries to reflect deselection + setParcelGeometries(prev => prev.map(geo => ({ ...geo, isSelected: false }))); + }, []); + + /** + * Handle parcel click on map - toggle parcel selection + */ + const handleParcelClick = useCallback(async (parcelId: string) => { + // Check if parcel is already selected + const isSelected = isParcelSelected(parcelId); + + if (isSelected) { + // Remove from selection + removeParcel(parcelId); + } else { + // Find the clicked parcel in the geometries + const clickedParcel = parcelGeometries.find(p => p.id === parcelId); + + if (clickedParcel && clickedParcel.coordinates.length > 0) { + // Use a point inside the parcel (first coordinate is always on the boundary, which is inside) + const firstCoord = clickedParcel.coordinates[0]; + + // Use first coordinate (guaranteed to be on/in the parcel) for search + const locationString = `${firstCoord.x},${firstCoord.y}`; + await searchParcel(locationString, true); // Always include adjacent parcels + } else { + // Fallback: try to search by parcel ID/EGRID if available + // Check all selected parcels for adjacent parcels + for (const selectedParcel of selectedParcels) { + if (selectedParcel.adjacent_parcels) { + const adjacentParcel = selectedParcel.adjacent_parcels.find(p => p.id === parcelId); + if (adjacentParcel?.egrid) { + // Search by EGRID + await searchParcel(adjacentParcel.egrid, true); + break; + } else if (adjacentParcel?.number) { + // Try searching by number (might need address context) + await searchParcel(adjacentParcel.number, true); + break; + } else if (adjacentParcel?.id) { + // Last resort: try searching by ID + await searchParcel(adjacentParcel.id, true); + break; + } + } + } + } + } + }, [parcelGeometries, selectedParcels, isParcelSelected, removeParcel, searchParcel]); + + /** + * Process natural language command + * Always includes the currently selected parcel if available + */ + const processCommand = useCallback(async (userInput: string) => { + if (!userInput.trim()) { + setCommandError('Bitte geben Sie einen Befehl ein'); + return; + } + + setIsProcessingCommand(true); + setCommandError(null); + + // Add user message + const userMessage = { + id: `user-${Date.now()}`, + role: 'user', + message: userInput.trim(), + timestamp: Date.now() + }; + setCommandResults((prev) => [...prev, userMessage]); + + try { + // Build request body with user input and selected parcel + const requestBody: any = { + userInput: userInput.trim() + }; + + // Always include the currently selected parcels if available + if (selectedParcels.length > 0) { + // Use first selected parcel for backward compatibility + const firstParcel = selectedParcels[0]; + requestBody.selectedParcel = { + id: firstParcel.parcel.id, + egrid: firstParcel.parcel.egrid, + number: firstParcel.parcel.number, + name: firstParcel.parcel.name, + identnd: firstParcel.parcel.identnd, + canton: firstParcel.parcel.canton, + municipality_code: firstParcel.parcel.municipality_code, + municipality_name: firstParcel.parcel.municipality_name, + address: firstParcel.parcel.address, + area_m2: firstParcel.parcel.area_m2, + centroid: firstParcel.parcel.centroid, + geoportal_url: firstParcel.parcel.geoportal_url, + realestate_type: firstParcel.parcel.realestate_type, + bauzone: firstParcel.parcel.bauzone, + zone: firstParcel.parcel.zone, + // Include geometry data if available + geometry_geojson: firstParcel.map_view?.geometry_geojson, + perimeter: firstParcel.parcel.perimeter + }; + // Also include all selected parcels as array + requestBody.selectedParcels = selectedParcels.map(p => ({ + id: p.parcel.id, + egrid: p.parcel.egrid, + number: p.parcel.number, + name: p.parcel.name, + identnd: p.parcel.identnd, + canton: p.parcel.canton, + municipality_code: p.parcel.municipality_code, + municipality_name: p.parcel.municipality_name, + address: p.parcel.address, + area_m2: p.parcel.area_m2, + centroid: p.parcel.centroid, + geoportal_url: p.parcel.geoportal_url, + realestate_type: p.parcel.realestate_type, + bauzone: p.parcel.bauzone, + zone: p.parcel.zone, + geometry_geojson: p.map_view?.geometry_geojson, + perimeter: p.parcel.perimeter + })); + } + + const response = await api.post('/api/realestate/command', requestBody); + + const data: CommandResponse = response.data; + + // Format response as assistant message + let responseMessage = ''; + if (data.success) { + if (data.result) { + if (typeof data.result === 'object') { + responseMessage = `**Intent:** ${data.intent || 'Unknown'}\n**Entity:** ${data.entity || 'N/A'}\n\n**Result:**\n\`\`\`json\n${JSON.stringify(data.result, null, 2)}\n\`\`\``; + } else { + responseMessage = `**Intent:** ${data.intent || 'Unknown'}\n**Entity:** ${data.entity || 'N/A'}\n\n**Result:** ${data.result}`; + } + } else { + responseMessage = `Command executed successfully.\n**Intent:** ${data.intent || 'Unknown'}\n**Entity:** ${data.entity || 'N/A'}`; + } + } else { + responseMessage = `Error: ${data.error || 'Unknown error'}`; + } + + const assistantMessage = { + id: `assistant-${Date.now()}`, + role: 'assistant', + message: responseMessage, + timestamp: Date.now() + }; + setCommandResults((prev) => [...prev, assistantMessage]); + + // If a project was created and there are selected parcels, automatically add them + if (data.success && data.intent === 'CREATE' && data.entity === 'Projekt' && selectedParcels.length > 0) { + try { + // Extract projekt from result + const projektResult = data.result?.result || data.result; + if (projektResult?.id) { + // Set as current projekt + setCurrentProjekt(projektResult); + + // Add all selected parcels to the newly created project via direct API call + let addedCount = 0; + for (const selectedParcel of selectedParcels) { + try { + const addParcelRequestBody: any = { + parcelId: selectedParcel.parcel.id, + parcelData: { + id: selectedParcel.parcel.id, + egrid: selectedParcel.parcel.egrid, + number: selectedParcel.parcel.number, + name: selectedParcel.parcel.name, + identnd: selectedParcel.parcel.identnd, + canton: selectedParcel.parcel.canton, + municipality_code: selectedParcel.parcel.municipality_code, + municipality_name: selectedParcel.parcel.municipality_name, + address: selectedParcel.parcel.address, + area_m2: selectedParcel.parcel.area_m2, + centroid: selectedParcel.parcel.centroid, + geoportal_url: selectedParcel.parcel.geoportal_url, + realestate_type: selectedParcel.parcel.realestate_type, + bauzone: selectedParcel.parcel.bauzone, + zone: selectedParcel.parcel.zone, + geometry_geojson: selectedParcel.map_view?.geometry_geojson, + perimeter: selectedParcel.parcel.perimeter + } + }; + + const addResponse = await api.post( + `/api/realestate/projekt/${projektResult.id}/add-parcel`, + addParcelRequestBody + ); + const addResult: AddParcelResponse = addResponse.data; + + // Update current projekt with the updated version that includes the parcel + setCurrentProjekt(addResult.projekt); + addedCount++; + } catch (addError: any) { + console.error(`Failed to add parcel ${selectedParcel.parcel.id} to project:`, addError); + } + } + + // Update the assistant message to indicate parcels were added + const parcelText = addedCount === 1 ? 'Parzelle' : 'Parzellen'; + const updateMessage = { + ...assistantMessage, + id: `assistant-update-${Date.now()}`, + message: `${responseMessage}\n\n✅ ${addedCount} ${parcelText} ${addedCount === 1 ? 'wurde' : 'wurden'} automatisch zum Projekt hinzugefügt.` + }; + setCommandResults((prev) => { + const updated = [...prev]; + const lastIndex = updated.length - 1; + if (updated[lastIndex]?.id === assistantMessage.id) { + updated[lastIndex] = updateMessage; + } + return updated; + }); + } + } catch (addError: any) { + // Log error but don't fail the command + console.error('Failed to automatically add parcel to project:', addError); + const errorMessage = addError.response?.data?.detail || addError.message || 'Unbekannter Fehler'; + const errorUpdate = { + id: `assistant-error-${Date.now()}`, + role: 'assistant', + message: `⚠️ Projekt wurde erstellt, aber Parzelle konnte nicht automatisch hinzugefügt werden: ${errorMessage}`, + timestamp: Date.now() + }; + setCommandResults((prev) => [...prev, errorUpdate]); + } + } + + // If a parcel was created and there are selected parcels, automatically populate it with the first selected parcel data + if (data.success && data.intent === 'CREATE' && data.entity === 'Parzelle' && selectedParcels.length > 0) { + const selectedParcel = selectedParcels[0]; // Use first selected parcel + try { + // Extract parzelle from result + const parzelleResult = data.result?.result || data.result; + if (parzelleResult?.id) { + // Update the newly created parcel with data from the selected parcel + const updateParcelRequestBody: any = { + // Map selected parcel data to parzelle fields + egrid: selectedParcel.parcel.egrid, + number: selectedParcel.parcel.number, + name: selectedParcel.parcel.name, + identnd: selectedParcel.parcel.identnd, + canton: selectedParcel.parcel.canton, + municipality_code: selectedParcel.parcel.municipality_code, + municipality_name: selectedParcel.parcel.municipality_name, + address: selectedParcel.parcel.address, + strasseNr: selectedParcel.parcel.address, + area_m2: selectedParcel.parcel.area_m2, + centroid: selectedParcel.parcel.centroid, + geoportal_url: selectedParcel.parcel.geoportal_url, + realestate_type: selectedParcel.parcel.realestate_type, + bauzone: selectedParcel.parcel.bauzone, + zone: selectedParcel.parcel.zone, + // Include geometry data + geometry_geojson: selectedParcel.map_view?.geometry_geojson, + perimeter: selectedParcel.parcel.perimeter + }; + + // Try to update the parcel via PUT request + try { + await api.put( + `/api/realestate/parzelle/${parzelleResult.id}`, + updateParcelRequestBody + ); + + // Update the assistant message to indicate parcel was populated + const updateMessage = { + ...assistantMessage, + id: `assistant-update-${Date.now()}`, + message: `${responseMessage}\n\n✅ Parzelle wurde automatisch mit Daten der Kartenauswahl befüllt.` + }; + setCommandResults((prev) => { + const updated = [...prev]; + const lastIndex = updated.length - 1; + if (updated[lastIndex]?.id === assistantMessage.id) { + updated[lastIndex] = updateMessage; + } + return updated; + }); + } catch (putError: any) { + // If PUT doesn't work, try PATCH + try { + await api.patch( + `/api/realestate/parzelle/${parzelleResult.id}`, + updateParcelRequestBody + ); + + const updateMessage = { + ...assistantMessage, + id: `assistant-update-${Date.now()}`, + message: `${responseMessage}\n\n✅ Parzelle wurde automatisch mit Daten der Kartenauswahl befüllt.` + }; + setCommandResults((prev) => { + const updated = [...prev]; + const lastIndex = updated.length - 1; + if (updated[lastIndex]?.id === assistantMessage.id) { + updated[lastIndex] = updateMessage; + } + return updated; + }); + } catch (patchError: any) { + // If both PUT and PATCH fail, log but don't fail the command + console.error('Failed to update parcel with selected parcel data:', patchError); + const errorMessage = patchError.response?.data?.detail || patchError.message || 'Unbekannter Fehler'; + const errorUpdate = { + id: `assistant-error-${Date.now()}`, + role: 'assistant', + message: `⚠️ Parzelle wurde erstellt, aber konnte nicht automatisch mit Kartenauswahl-Daten befüllt werden: ${errorMessage}`, + timestamp: Date.now() + }; + setCommandResults((prev) => [...prev, errorUpdate]); + } + } + } + } catch (updateError: any) { + // Log error but don't fail the command + console.error('Failed to automatically populate parcel with selected parcel data:', updateError); + const errorMessage = updateError.response?.data?.detail || updateError.message || 'Unbekannter Fehler'; + const errorUpdate = { + id: `assistant-error-${Date.now()}`, + role: 'assistant', + message: `⚠️ Parzelle wurde erstellt, aber konnte nicht automatisch mit Kartenauswahl-Daten befüllt werden: ${errorMessage}`, + timestamp: Date.now() + }; + setCommandResults((prev) => [...prev, errorUpdate]); + } + } + + // Clear input on success + setCommandInput(''); + + return { success: true, data }; + } catch (err: any) { + const errorMessage = err.response?.data?.detail || err.message || 'Failed to send command'; + setCommandError(errorMessage); + + // Add error message + const errorMsg = { + id: `error-${Date.now()}`, + role: 'assistant', + message: `**Error:** ${errorMessage}`, + timestamp: Date.now() + }; + setCommandResults((prev) => [...prev, errorMsg]); + + return { success: false, error: errorMessage }; + } finally { + setIsProcessingCommand(false); + } + }, [selectedParcels]); + + /** + * Create a new project + */ + const createProjekt = useCallback( + async (data: { + label: string; + statusProzess?: string; + location?: string; + parcelIds?: string[]; + }) => { + setIsCreatingProjekt(true); + setProjektError(null); + + try { + const requestBody: any = { + label: data.label + }; + + if (data.statusProzess) { + requestBody.statusProzess = data.statusProzess; + } + + if (data.location) { + requestBody.location = data.location; + } + + if (data.parcelIds && data.parcelIds.length > 0) { + requestBody.parcelIds = data.parcelIds; + } + + const response = await api.post('/api/realestate/projekt/create', requestBody); + const result: CreateProjektResponse = response.data; + + setCurrentProjekt(result.projekt); + return { success: true, data: result }; + } catch (err: any) { + const errorMessage = + err.response?.data?.detail || err.message || 'Fehler beim Erstellen des Projekts'; + setProjektError(errorMessage); + return { success: false, error: errorMessage }; + } finally { + setIsCreatingProjekt(false); + } + }, + [] + ); + + /** + * Add a parcel to an existing project + */ + const addParcelToProjekt = useCallback( + async ( + projektId: string, + data: { + parcelId?: string; + location?: string; + parcelData?: Record; + } + ) => { + if (!currentProjekt || currentProjekt.id !== projektId) { + setProjektError('Projekt nicht gefunden'); + return { success: false, error: 'Projekt nicht gefunden' }; + } + + setIsAddingParcel(true); + setProjektError(null); + + try { + const requestBody: any = {}; + + if (data.parcelId) { + requestBody.parcelId = data.parcelId; + } else if (data.location) { + requestBody.location = data.location; + } else if (data.parcelData) { + requestBody.parcelData = data.parcelData; + } else { + throw new Error('Bitte geben Sie parcelId, location oder parcelData an'); + } + + const response = await api.post( + `/api/realestate/projekt/${projektId}/add-parcel`, + requestBody + ); + const result: AddParcelResponse = response.data; + + setCurrentProjekt(result.projekt); + return { success: true, data: result }; + } catch (err: any) { + const errorMessage = + err.response?.data?.detail || err.message || 'Fehler beim Hinzufügen der Parzelle'; + setProjektError(errorMessage); + return { success: false, error: errorMessage }; + } finally { + setIsAddingParcel(false); + } + }, + [currentProjekt] + ); + + // Build location string from separate fields + const buildLocationString = useCallback(() => { + const parts = [kanton, gemeinde, adresse].filter(Boolean); + return parts.join(', '); + }, [kanton, gemeinde, adresse]); + + return { + // Location input - separate fields + kanton, + setKanton, + gemeinde, + setGemeinde, + adresse, + setAdresse, + buildLocationString, + // Legacy locationInput for backward compatibility + locationInput, + setLocationInput, + useCurrentLocation, + isGettingLocation, + locationError, + + // Parcel search + selectedParcels, + searchParcel, + isSearchingParcel, + parcelSearchError, + removeParcel, + clearSelectedParcels, + isParcelSelected, + + // Map view + mapCenter, + mapZoomBounds, + parcelGeometries, + handleMapClick, + handleParcelClick, + + // Command processing + commandInput, + setCommandInput, + processCommand, + isProcessingCommand, + commandResults, + commandError, + + // Project management + currentProjekt, + createProjekt, + isCreatingProjekt, + addParcelToProjekt, + isAddingParcel, + projektError, + + // Panel state + isPanelOpen, + setIsPanelOpen + }; +} diff --git a/src/pages/admin/AccessManagementHub.module.css b/src/pages/admin/AccessManagementHub.module.css new file mode 100644 index 0000000..b724863 --- /dev/null +++ b/src/pages/admin/AccessManagementHub.module.css @@ -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); +} diff --git a/src/pages/admin/AccessManagementHub.tsx b/src/pages/admin/AccessManagementHub.tsx new file mode 100644 index 0000000..f31561b --- /dev/null +++ b/src/pages/admin/AccessManagementHub.tsx @@ -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([]); + const [selectedMandateId, setSelectedMandateId] = useState(''); + const [selectedFeatureCode, setSelectedFeatureCode] = useState(''); + const [instancesWithStats, setInstancesWithStats] = useState([]); + 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('hierarchy'); + const [instanceUsersMap, setInstanceUsersMap] = useState>({}); + const [hierarchyUsersLoading, setHierarchyUsersLoading] = useState(false); + const [instancesByMandate, setInstancesByMandate] = useState>({}); + + 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 = {}; + 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 = {}; + 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 ( +
+
+ ⚠️ +

Fehler: {error}

+ +
+
+ ); + } + + return ( +
+
+
+

Zugriffsverwaltung

+

+ Feature-Instanzen, Benutzer und Rollen an einem Ort verwalten +

+
+
+ +
+
+ + +
+
+ + +
+ {selectedMandateId && ( +
+ + +
+ )} +
+ +
+
+ + +
+ + Mandanten verwalten + +
+ + {viewMode === 'hierarchy' ? ( + + ) : !selectedMandateId ? ( +
+ +

Kein Mandant ausgewählt

+

+ Wählen Sie einen Mandanten, um dessen Feature-Instanzen und Zugriffe zu verwalten. +

+
+ ) : ( + <> +
+
+ +
+ + {loading || statsLoading ? '…' : overviewStats.instances} + + Instanzen +
+
+
+ +
+ + {loading || statsLoading ? '…' : overviewStats.users} + + Benutzer +
+
+
+ +
+ + {loading || statsLoading ? '…' : overviewStats.roles} + + Rollen (max) +
+
+ {relationshipData && relationshipData.instances.length > 0 && ( +
+ +
+ Beziehungen +
+
{relationshipData.mandateName}
+
+ {relationshipData.instances.slice(0, 5).map((inst) => ( +
+ {inst.label} ({inst.userCount}) +
+ ))} + {relationshipData.instances.length > 5 && ( +
+ +{relationshipData.instances.length - 5} weitere +
+ )} +
+
+
+
+ )} +
+ +
+

Feature-Instanzen

+ {loading && filteredInstances.length === 0 ? ( +
+
+ Lade Instanzen... +
+ ) : filteredInstances.length === 0 ? ( +
+ +

Keine Feature-Instanzen

+

+ Erstellen Sie eine neue Instanz oder wählen Sie ein anderes Feature. +

+ +
+ ) : ( +
+ {filteredInstances.map((inst) => ( +
+
+ {inst.label} + + {inst.enabled ? 'Aktiv' : 'Inaktiv'} + +
+
+ {getFeatureLabel(features.find((f) => f.code === inst.featureCode) || { code: inst.featureCode, label: inst.featureCode })} + {inst.userCount ?? '—'} Benutzer + {inst.roleCount ?? '—'} Rollen +
+
+ + +
+
+ ))} +
+ )} +
+ + )} + + {detailInstance && ( + + )} + + {showWizard && ( + setShowWizard(false)} + onComplete={handleWizardComplete} + /> + )} +
+ ); +}; + +export default AccessManagementHub; diff --git a/src/pages/admin/AdminMandateRolesPage.tsx b/src/pages/admin/AdminMandateRolesPage.tsx index 22b7fba..a6fc149 100644 --- a/src/pages/admin/AdminMandateRolesPage.tsx +++ b/src/pages/admin/AdminMandateRolesPage.tsx @@ -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 = () => {

Rollen

Verwalten Sie System-, globale und mandantenspezifische Rollen

+
+ + +
{/* Mandate Selector and Filters */} diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx index 7a649b6..b26de67 100644 --- a/src/pages/admin/AdminUsersPage.tsx +++ b/src/pages/admin/AdminUsersPage.tsx @@ -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 = () => {

Verwalten Sie alle Benutzer im System

+ +
+ +
+ {STEPS.map((s, i) => ( +
+ {i + 1} +
+ ))} +
+ +
+ {currentStepId === 'create' && ( +
+ + +
+ )} + + {currentStepId === 'roles' && ( +
+

+ Die Rollen wurden beim Erstellen der Instanz übernommen. Sie können später unter „Benutzer verwalten“ weitere Rollen synchronisieren. +

+
+ + +
+
+ )} + + {currentStepId === 'users' && ( +
+

+ Optional: Weisen Sie Benutzern Rollen zu. Sie können dies auch später in der Zugriffsverwaltung tun. +

+ {mandateUsers.length === 0 ? ( +

Keine Mandanten-Benutzer vorhanden.

+ ) : ( +
+ {mandateUsers.map((u) => { + const selected = selectedUserRoles.find((s) => s.userId === u.id); + const roleIds = selected?.roleIds ?? []; + return ( +
+ {u.username} + +
+ ); + })} +
+ )} +
+ + +
+
+ )} +
+ + + ); +}; + +export default FeatureInstanceWizard; diff --git a/src/pages/admin/InstanceDetailModal.module.css b/src/pages/admin/InstanceDetailModal.module.css new file mode 100644 index 0000000..4997e6d --- /dev/null +++ b/src/pages/admin/InstanceDetailModal.module.css @@ -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; +} diff --git a/src/pages/admin/InstanceDetailModal.tsx b/src/pages/admin/InstanceDetailModal.tsx new file mode 100644 index 0000000..810634d --- /dev/null +++ b/src/pages/admin/InstanceDetailModal.tsx @@ -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 = ({ + instance, + mandateId, + mandateName, + featureLabel, + onClose, + onSaved, +}) => { + const { + fetchInstanceUsers, + fetchInstanceRoles, + addUserToInstance, + removeUserFromInstance, + updateInstanceUserRoles, + syncInstanceRoles, + updateInstance, + } = useFeatureAccess(); + const { showSuccess, showError } = useToast(); + + const [users, setUsers] = useState([]); + const [roles, setRoles] = useState>([]); + const [allUsers, setAllUsers] = useState>([]); + const [loading, setLoading] = useState(true); + const [showAddModal, setShowAddModal] = useState(false); + const [editingUser, setEditingUser] = useState(null); + const [syncing, setSyncing] = useState(false); + const [roleOptions, setRoleOptions] = useState([]); + + 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: ( +
+ {loading ? ( +
+
+ Lade Benutzer... +
+ ) : ( + setShowAddModal(true)} + /> + )} +
+ ), + }, + { + id: 'roles', + label: 'Rollen', + content: ( +
+

+ Rollen werden von der Feature-Vorlage übernommen. Mit „Synchronisieren“ können Sie fehlende Rollen nachziehen. +

+
    + {roles.map((r) => ( +
  • {r.roleLabel}
  • + ))} +
+ +
+ ), + }, + { + id: 'settings', + label: 'Einstellungen', + content: ( +
+ {}} + /> +
+ ), + }, + ]; + + return ( +
+
e.stopPropagation()}> +
+
+

{instance.label}

+

+ {mandateName} · {featureLabel} +

+
+ +
+
+ +
+
+ + {showAddModal && ( +
setShowAddModal(false)}> +
e.stopPropagation()}> +
+

Benutzer hinzufügen

+ +
+
+ {availableUsers.length === 0 ? ( +

Alle Mandanten-Benutzer haben bereits Zugriff.

+ ) : addUserFields.length < 2 || !roleOptions?.length ? ( +

Laden...

+ ) : ( + setShowAddModal(false)} + submitButtonText="Hinzufügen" + cancelButtonText="Abbrechen" + /> + )} +
+
+
+ )} + + {editingUser && ( +
setEditingUser(null)}> +
e.stopPropagation()}> +
+

Rollen: {editingUser.username}

+ +
+
+ setEditingUser(null)} + submitButtonText="Speichern" + cancelButtonText="Abbrechen" + /> +
+
+
+ )} +
+ ); +}; + +export default InstanceDetailModal; diff --git a/src/pages/admin/InstanceHierarchyView.module.css b/src/pages/admin/InstanceHierarchyView.module.css new file mode 100644 index 0000000..0a187c1 --- /dev/null +++ b/src/pages/admin/InstanceHierarchyView.module.css @@ -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); +} diff --git a/src/pages/admin/InstanceHierarchyView.tsx b/src/pages/admin/InstanceHierarchyView.tsx new file mode 100644 index 0000000..64c5874 --- /dev/null +++ b/src/pages/admin/InstanceHierarchyView.tsx @@ -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; + instanceUsersMap: Record; + 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; + 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>(new Set()); + + const byFeature = useMemo(() => { + const map: Record = {}; + 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 ( +
+
+ +
+ {mandateName} + + {mandateMeta.featureCount} Feature{mandateMeta.featureCount !== 1 ? 's' : ''} · {mandateMeta.instanceCount} Instanz{mandateMeta.instanceCount !== 1 ? 'en' : ''} + +
+
+ + {Object.entries(byFeature).map(([featureCode, featureInstances]) => { + const featureUserCount = featureInstances.reduce( + (sum, inst) => sum + (instanceUsersMap[inst.id]?.length ?? 0), + 0 + ); + return ( +
+
+ + {getFeatureLabelSafe(features, featureCode, getFeatureLabel)} + + + {featureInstances.length} Instanz{featureInstances.length !== 1 ? 'en' : ''} + {featureUserCount > 0 && ` · ${featureUserCount} Benutzer`} + +
+ + {featureInstances.map((inst) => { + const isExpanded = expandedInstanceIds.has(inst.id); + const users = instanceUsersMap[inst.id] ?? []; + + return ( +
+
+ + +
+ + {isExpanded && ( +
+ {users.length === 0 ? ( +
+ Keine Benutzer zugeordnet.{' '} + +
+ ) : ( + users.map((u) => ) + )} +
+ )} +
+ ); + })} +
+ ); + })} +
+ ); +} + +export const InstanceHierarchyView: React.FC = ({ + mandates, + getMandateName, + instancesByMandate, + instanceUsersMap, + features, + getFeatureLabel, + loading, + onOpenDetail, +}) => { + const [expandedMandateIds, setExpandedMandateIds] = useState>(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 ( +
+
+ + Lade Hierarchie und Benutzer... +
+
+ ); + } + + if (mandates.length === 0) { + return ( +
+

Hierarchie

+
+ Keine Mandanten vorhanden. Legen Sie unter "Mandanten verwalten" einen Mandanten an. +
+
+ ); + } + + return ( +
+

Hierarchie

+
+ {mandates.map((mandate) => { + const mandateId = mandate.id; + const instances = instancesByMandate[mandateId] ?? []; + const isExpanded = expandedMandateIds.has(mandateId); + const mandateName = getMandateName(mandate); + const byFeat: Record = {}; + instances.forEach((inst) => { + if (!byFeat[inst.featureCode]) byFeat[inst.featureCode] = []; + byFeat[inst.featureCode].push(inst); + }); + const featureCount = Object.keys(byFeat).length; + + return ( +
+ + {isExpanded && ( + + )} +
+ ); + })} +
+
+ ); +}; + +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 ( +
+
+ {displayName} + {rolesText} + + {statusText} + +
+
+
Berechtigungen
+ {user.email && ( +
{user.email}
+ )} +
{rolesText}
+
Zugang: {statusText}
+
+
+ ); +} + +export default InstanceHierarchyView; diff --git a/src/pages/admin/PermissionMatrix.module.css b/src/pages/admin/PermissionMatrix.module.css new file mode 100644 index 0000000..5d38f8d --- /dev/null +++ b/src/pages/admin/PermissionMatrix.module.css @@ -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); +} diff --git a/src/pages/admin/PermissionMatrix.tsx b/src/pages/admin/PermissionMatrix.tsx new file mode 100644 index 0000000..7738610 --- /dev/null +++ b/src/pages/admin/PermissionMatrix.tsx @@ -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 = ({ + users, + roles, + onEditUser, + onRemoveUser, + onAddUser, + disabled = false, +}) => { + const [removingId, setRemovingId] = useState(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 ( +
+

Keine Rollen in dieser Instanz. Bitte zuerst Rollen synchronisieren.

+
+ ); + } + + return ( +
+
+ + + + + {roles.map((r) => ( + + ))} + + + + + + {users.length === 0 ? ( + + + + ) : ( + users.map((user) => ( + + + {roles.map((role) => { + const hasRole = user.roleIds?.includes(role.id) ?? false; + return ( + + ); + })} + + + + )) + )} + +
Benutzer + {r.roleLabel} + AktivAktionen
+ Keine Benutzer zugewiesen. +
+ {user.username} + {user.email && ( + {user.email} + )} + + + {hasRole ? '✓' : '—'} + + + + {user.enabled ? '✓' : '—'} + + + + +
+
+
+ +
+
+ ); +}; + +export default PermissionMatrix; diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts index 217597b..27fb4ae 100644 --- a/src/pages/admin/index.ts +++ b/src/pages/admin/index.ts @@ -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'; diff --git a/src/pages/views/realestate/RealEstatePekView.tsx b/src/pages/views/realestate/RealEstatePekView.tsx new file mode 100644 index 0000000..6eb244d --- /dev/null +++ b/src/pages/views/realestate/RealEstatePekView.tsx @@ -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 ( +
+

+ {t('projects.description_text')} +

+ + + + + {/* Optional: Command input and results */} +
+
+
+ +
+
+ +
+ +
+
+
+ + {commandResults.length > 0 && ( +
+

Antworten

+
+ {commandResults.map((msg: any) => ( +
+ {msg.role === 'user' ? 'Sie' : 'Assistent'}:{' '} + {typeof msg.message === 'string' ? msg.message : JSON.stringify(msg.message)} +
+ ))} +
+
+ )} +
+
+ ); +} + +export const RealEstatePekView: React.FC = () => { + return ( + + + + ); +}; + +export default RealEstatePekView; diff --git a/src/pages/views/realestate/pek/PekLocationInput.module.css b/src/pages/views/realestate/pek/PekLocationInput.module.css new file mode 100644 index 0000000..7425a23 --- /dev/null +++ b/src/pages/views/realestate/pek/PekLocationInput.module.css @@ -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; + } +} diff --git a/src/pages/views/realestate/pek/PekLocationInput.tsx b/src/pages/views/realestate/pek/PekLocationInput.tsx new file mode 100644 index 0000000..37c5d22 --- /dev/null +++ b/src/pages/views/realestate/pek/PekLocationInput.tsx @@ -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 ( +
+
+
+ { + if (e.key === 'Enter') { + e.preventDefault(); + handleSearch(); + } + }} + /> +
+
+ + +
+
+
+ ); +}; + +export default PekLocationInput; diff --git a/src/pages/views/realestate/pek/PekMapView.tsx b/src/pages/views/realestate/pek/PekMapView.tsx new file mode 100644 index 0000000..d11364c --- /dev/null +++ b/src/pages/views/realestate/pek/PekMapView.tsx @@ -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(); + 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 ( + <> +
+ +
+ + setIsPanelOpen(false)} + parcels={selectedParcels} + onRemoveParcel={removeParcel} + adjacentParcels={allAdjacentParcels} + /> + + ); +}; + +export default PekMapView;