From 5952074626f6081341b04842d7169e56e8f5dde5 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 24 Jan 2026 09:58:04 +0100 Subject: [PATCH] access rules editor enhanced --- src/App.tsx | 2 + .../AccessRules/AccessRulesEditor.tsx | 6 +- .../Navigation/MandateNavigation.tsx | 2 +- .../TreeNavigation/TreeNavigation.tsx | 2 +- src/hooks/useAccessRules.ts | 263 +++++---- src/hooks/useAccessRules.tsx | 254 ++++++++ src/layouts/FeatureLayout.tsx | 2 +- src/pages/Dashboard.tsx | 2 +- src/pages/FeatureView.tsx | 13 +- src/pages/admin/AdminFeatureRolesPage.tsx | 45 +- src/pages/views/trustee/TrusteeAccessView.tsx | 247 -------- .../views/trustee/TrusteeContractsView.tsx | 209 ------- .../views/trustee/TrusteeDashboardView.tsx | 52 +- .../views/trustee/TrusteeDocumentsView.tsx | 483 +++++++++------- .../trustee/TrusteeInstanceRolesView.tsx | 180 ++++++ .../trustee/TrusteeOrganisationsView.tsx | 222 ------- .../trustee/TrusteePositionDocumentsView.tsx | 393 +++++++------ .../views/trustee/TrusteePositionsView.tsx | 545 ++++++++---------- src/pages/views/trustee/TrusteeRolesView.tsx | 197 ------- .../views/trustee/TrusteeViews.module.css | 148 +++++ .../trustee/components/TrusteeEditForm.tsx | 329 ----------- src/pages/views/trustee/components/index.ts | 7 +- src/pages/views/trustee/index.ts | 8 +- src/types/mandate.ts | 11 +- 24 files changed, 1544 insertions(+), 2078 deletions(-) create mode 100644 src/hooks/useAccessRules.tsx delete mode 100644 src/pages/views/trustee/TrusteeAccessView.tsx delete mode 100644 src/pages/views/trustee/TrusteeContractsView.tsx create mode 100644 src/pages/views/trustee/TrusteeInstanceRolesView.tsx delete mode 100644 src/pages/views/trustee/TrusteeOrganisationsView.tsx delete mode 100644 src/pages/views/trustee/TrusteeRolesView.tsx delete mode 100644 src/pages/views/trustee/components/TrusteeEditForm.tsx diff --git a/src/App.tsx b/src/App.tsx index 0aeee8f..e3f2da9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -149,6 +149,8 @@ function App() { } /> } /> } /> + } /> + } /> {/* Catch-all für unbekannte Sub-Pfade */} } /> diff --git a/src/components/AccessRules/AccessRulesEditor.tsx b/src/components/AccessRules/AccessRulesEditor.tsx index 4614027..f4f1f82 100644 --- a/src/components/AccessRules/AccessRulesEditor.tsx +++ b/src/components/AccessRules/AccessRulesEditor.tsx @@ -37,6 +37,8 @@ interface AccessRulesEditorProps { isTemplate?: boolean; readOnly?: boolean; onSave?: () => void; + apiBasePath?: string; + mandateId?: string; } type TabType = 'DATA' | 'UI' | 'RESOURCE' | 'JSON'; @@ -409,6 +411,8 @@ export const AccessRulesEditor: React.FC = ({ isTemplate = false, readOnly = false, onSave, + apiBasePath = '/api/rbac', + mandateId, }) => { const { rules, @@ -421,7 +425,7 @@ export const AccessRulesEditor: React.FC = ({ updateRuleLocally, addRuleLocally, removeRuleLocally, - } = useAccessRules(roleId); + } = useAccessRules(roleId, apiBasePath, mandateId); const [activeTab, setActiveTab] = useState('DATA'); const [hasChanges, setHasChanges] = useState(false); diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index 805691c..0c42426 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -75,7 +75,7 @@ function instanceToTreeNode( return { id: instance.id, label: instance.instanceLabel, - badge: instance.userRole, + // Note: badge für userRole entfernt - ein User kann mehrere Rollen haben children, defaultExpanded: false, }; diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx index 0e1af84..4cb31ed 100644 --- a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx +++ b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx @@ -216,7 +216,7 @@ const TreeNode: React.FC = ({ )} {node.icon && {node.icon}} - {node.label} + {node.label} {node.badge !== undefined && ( { return option?.color || '#718096'; }; +export interface AccessRule { + id: string; + roleId: string; + context: RuleContext; + item: string | null; + view: boolean; + read: AccessLevel; + create: AccessLevel; + update: AccessLevel; + delete: AccessLevel; +} + +export interface AccessRuleCreate { + context: RuleContext; + item?: string | null; + view?: boolean; + read?: AccessLevel; + create?: AccessLevel; + update?: AccessLevel; + delete?: AccessLevel; +} + +interface GroupedRules { + DATA: AccessRule[]; + UI: AccessRule[]; + RESOURCE: AccessRule[]; +} + +interface SaveResult { + success: boolean; + error?: string; +} + // ============================================================================= // HOOK // ============================================================================= -export function useAccessRules(roleId: string | null) { +export function useAccessRules(roleId: string, apiBasePath: string = '/api/rbac') { const [rules, setRules] = useState([]); const [loading, setLoading] = useState(false); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + // Determine if this is a feature-instance API path + const isInstanceApi = apiBasePath.includes('/instance-roles/'); + /** * Fetch all rules for the role */ const fetchRules = useCallback(async (): Promise => { - if (!roleId) { - setRules([]); - return []; - } - setLoading(true); setError(null); + try { - const response = await api.get(`/api/rbac/roles/${roleId}/rules`); - const fetchedRules = Array.isArray(response.data) ? response.data : response.data.rules || []; + // Different endpoint structure for instance roles vs system roles + const endpoint = isInstanceApi + ? `${apiBasePath}/rules` + : `${apiBasePath}/rules/by-role/${roleId}`; + + const response = await api.get(endpoint); + const fetchedRules = response.data?.items || response.data || []; setRules(fetchedRules); return fetchedRules; } catch (err: any) { - const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch access rules'; - setError(errorMessage); - setRules([]); + const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Laden der Regeln'; + setError(errorMsg); + console.error('Error fetching rules:', err); return []; } finally { setLoading(false); } - }, [roleId]); + }, [roleId, apiBasePath, isInstanceApi]); /** - * Save all rules for the role (bulk update) + * Save all rules for the role */ - const saveRules = useCallback(async (newRules: AccessRule[]): Promise<{ success: boolean; error?: string }> => { - if (!roleId) { - return { success: false, error: 'No role selected' }; - } - + const saveRules = useCallback(async (rulesToSave: AccessRule[]): Promise => { setSaving(true); setError(null); + try { - await api.put(`/api/rbac/roles/${roleId}/rules`, { rules: newRules }); - setRules(newRules); + // Different endpoint structure for instance roles vs system roles + const rulesEndpoint = isInstanceApi + ? `${apiBasePath}/rules` + : `${apiBasePath}/rules/by-role/${roleId}`; + + // Get current rules from server + const currentResponse = await api.get(rulesEndpoint); + const currentRules: AccessRule[] = currentResponse.data?.items || currentResponse.data || []; + const currentRuleIds = new Set(currentRules.map(r => r.id)); + + // Determine changes + const newRules = rulesToSave.filter(r => r.id.startsWith('temp-')); + const existingRules = rulesToSave.filter(r => !r.id.startsWith('temp-')); + const deletedRuleIds = [...currentRuleIds].filter( + id => !existingRules.some(r => r.id === id) + ); + + // Delete removed rules + for (const deletedId of deletedRuleIds) { + const deleteEndpoint = isInstanceApi + ? `${apiBasePath}/rules/${deletedId}` + : `${apiBasePath}/rules/${deletedId}`; + await api.delete(deleteEndpoint); + } + + // Create new rules + for (const rule of newRules) { + const createEndpoint = isInstanceApi + ? `${apiBasePath}/rules` + : `${apiBasePath}/rules`; + await api.post(createEndpoint, { + roleId, + context: rule.context, + item: rule.item, + view: rule.view, + read: rule.read, + create: rule.create, + update: rule.update, + delete: rule.delete, + }); + } + + // Update existing rules + for (const rule of existingRules) { + const original = currentRules.find(r => r.id === rule.id); + if (original && JSON.stringify(original) !== JSON.stringify(rule)) { + const updateEndpoint = isInstanceApi + ? `${apiBasePath}/rules/${rule.id}` + : `${apiBasePath}/rules/${rule.id}`; + await api.put(updateEndpoint, { + view: rule.view, + read: rule.read, + create: rule.create, + update: rule.update, + delete: rule.delete, + }); + } + } + + // Refresh rules + await fetchRules(); + return { success: true }; } catch (err: any) { - const errorMessage = err.response?.data?.detail || err.message || 'Failed to save access rules'; - setError(errorMessage); - return { success: false, error: errorMessage }; + const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Speichern'; + setError(errorMsg); + console.error('Error saving rules:', err); + return { success: false, error: errorMsg }; } finally { setSaving(false); } - }, [roleId]); - - /** - * Create a new rule - */ - const createRule = useCallback(async (ruleData: AccessRuleCreate): Promise<{ success: boolean; data?: AccessRule; error?: string }> => { - if (!roleId) { - return { success: false, error: 'No role selected' }; - } - - setSaving(true); - setError(null); - try { - const response = await api.post(`/api/rbac/roles/${roleId}/rules`, ruleData); - const newRule = response.data; - setRules(prev => [...prev, newRule]); - return { success: true, data: newRule }; - } catch (err: any) { - const errorMessage = err.response?.data?.detail || err.message || 'Failed to create access rule'; - setError(errorMessage); - return { success: false, error: errorMessage }; - } finally { - setSaving(false); - } - }, [roleId]); - - /** - * Update an existing rule - */ - const updateRule = useCallback(async (ruleId: string, updates: AccessRuleUpdate): Promise<{ success: boolean; error?: string }> => { - setSaving(true); - setError(null); - try { - const response = await api.patch(`/api/rbac/rules/${ruleId}`, updates); - setRules(prev => prev.map(r => r.id === ruleId ? { ...r, ...response.data } : r)); - return { success: true }; - } catch (err: any) { - const errorMessage = err.response?.data?.detail || err.message || 'Failed to update access rule'; - setError(errorMessage); - return { success: false, error: errorMessage }; - } finally { - setSaving(false); - } - }, []); - - /** - * Delete a rule - */ - const deleteRule = useCallback(async (ruleId: string): Promise<{ success: boolean; error?: string }> => { - setSaving(true); - setError(null); - try { - await api.delete(`/api/rbac/rules/${ruleId}`); - setRules(prev => prev.filter(r => r.id !== ruleId)); - return { success: true }; - } catch (err: any) { - const errorMessage = err.response?.data?.detail || err.message || 'Failed to delete access rule'; - setError(errorMessage); - return { success: false, error: errorMessage }; - } finally { - setSaving(false); - } - }, []); + }, [roleId, apiBasePath, isInstanceApi, fetchRules]); /** * Get rules grouped by context @@ -208,21 +204,23 @@ export function useAccessRules(roleId: string | null) { }, [rules]); /** - * Update rule locally (for optimistic updates) + * Update a rule locally (not saved until saveRules is called) */ const updateRuleLocally = useCallback((ruleId: string, updates: Partial) => { - setRules(prev => prev.map(r => r.id === ruleId ? { ...r, ...updates } : r)); + setRules(prev => prev.map(r => + r.id === ruleId ? { ...r, ...updates } : r + )); }, []); /** - * Add rule locally (for optimistic updates) + * Add a rule locally (not saved until saveRules is called) */ const addRuleLocally = useCallback((rule: AccessRule) => { setRules(prev => [...prev, rule]); }, []); /** - * Remove rule locally (for optimistic updates) + * Remove a rule locally (not saved until saveRules is called) */ const removeRuleLocally = useCallback((ruleId: string) => { setRules(prev => prev.filter(r => r.id !== ruleId)); @@ -235,9 +233,6 @@ export function useAccessRules(roleId: string | null) { error, fetchRules, saveRules, - createRule, - updateRule, - deleteRule, getGroupedRules, updateRuleLocally, addRuleLocally, diff --git a/src/hooks/useAccessRules.tsx b/src/hooks/useAccessRules.tsx new file mode 100644 index 0000000..16c6996 --- /dev/null +++ b/src/hooks/useAccessRules.tsx @@ -0,0 +1,254 @@ +/** + * useAccessRules Hook + * + * Hook for managing RBAC access rules for a role. + * Supports both system admin (template roles) and feature admin (instance roles). + */ + +import { useState, useCallback } from 'react'; +import api from '../api'; + +// ============================================================================= +// TYPES +// ============================================================================= + +export type RuleContext = 'DATA' | 'UI' | 'RESOURCE'; +export type AccessLevel = 'n' | 'm' | 'g' | 'a' | null; + +// ============================================================================= +// ACCESS LEVEL LABELS +// ============================================================================= + +export const ACCESS_LEVEL_OPTIONS: { value: 'n' | 'm' | 'g' | 'a'; label: string; color: string }[] = [ + { value: 'n', label: 'Keine', color: '#e53e3e' }, + { value: 'm', label: 'Eigene', color: '#d69e2e' }, + { value: 'g', label: 'Gruppe', color: '#3182ce' }, + { value: 'a', label: 'Alle', color: '#38a169' }, +]; + +export const getAccessLevelLabel = (level: AccessLevel | null): string => { + if (!level) return '-'; + const option = ACCESS_LEVEL_OPTIONS.find(opt => opt.value === level); + return option?.label || level; +}; + +export const getAccessLevelColor = (level: AccessLevel | null): string => { + if (!level) return '#718096'; + const option = ACCESS_LEVEL_OPTIONS.find(opt => opt.value === level); + return option?.color || '#718096'; +}; + +export interface AccessRule { + id: string; + roleId: string; + context: RuleContext; + item: string | null; + view: boolean; + read: AccessLevel; + create: AccessLevel; + update: AccessLevel; + delete: AccessLevel; +} + +export interface AccessRuleCreate { + context: RuleContext; + item?: string | null; + view?: boolean; + read?: AccessLevel; + create?: AccessLevel; + update?: AccessLevel; + delete?: AccessLevel; +} + +interface GroupedRules { + DATA: AccessRule[]; + UI: AccessRule[]; + RESOURCE: AccessRule[]; +} + +interface SaveResult { + success: boolean; + error?: string; +} + +// ============================================================================= +// HOOK +// ============================================================================= + +export function useAccessRules(roleId: string, apiBasePath: string = '/api/rbac', mandateId?: string) { + const [rules, setRules] = useState([]); + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + // Determine if this is a feature-instance API path + const isInstanceApi = apiBasePath.includes('/instance-roles/'); + + // Build headers with optional mandate ID + const getHeaders = useCallback(() => { + const headers: Record = {}; + if (mandateId) { + headers['X-Mandate-Id'] = mandateId; + } + return headers; + }, [mandateId]); + + /** + * Fetch all rules for the role + */ + const fetchRules = useCallback(async (): Promise => { + setLoading(true); + setError(null); + + try { + // Different endpoint structure for instance roles vs system roles + const endpoint = isInstanceApi + ? `${apiBasePath}/rules` + : `${apiBasePath}/rules/by-role/${roleId}`; + + const response = await api.get(endpoint, { headers: getHeaders() }); + const fetchedRules = response.data?.items || response.data || []; + setRules(fetchedRules); + return fetchedRules; + } catch (err: any) { + const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Laden der Regeln'; + setError(errorMsg); + console.error('Error fetching rules:', err); + return []; + } finally { + setLoading(false); + } + }, [roleId, apiBasePath, isInstanceApi, getHeaders]); + + /** + * Save all rules for the role + */ + const saveRules = useCallback(async (rulesToSave: AccessRule[]): Promise => { + setSaving(true); + setError(null); + + try { + const headers = getHeaders(); + + // Different endpoint structure for instance roles vs system roles + const rulesEndpoint = isInstanceApi + ? `${apiBasePath}/rules` + : `${apiBasePath}/rules/by-role/${roleId}`; + + // Get current rules from server + const currentResponse = await api.get(rulesEndpoint, { headers }); + const currentRules: AccessRule[] = currentResponse.data?.items || currentResponse.data || []; + const currentRuleIds = new Set(currentRules.map(r => r.id)); + + // Determine changes + const newRules = rulesToSave.filter(r => r.id.startsWith('temp-')); + const existingRules = rulesToSave.filter(r => !r.id.startsWith('temp-')); + const deletedRuleIds = [...currentRuleIds].filter( + id => !existingRules.some(r => r.id === id) + ); + + // Delete removed rules + for (const deletedId of deletedRuleIds) { + const deleteEndpoint = isInstanceApi + ? `${apiBasePath}/rules/${deletedId}` + : `${apiBasePath}/rules/${deletedId}`; + await api.delete(deleteEndpoint, { headers }); + } + + // Create new rules + for (const rule of newRules) { + const createEndpoint = isInstanceApi + ? `${apiBasePath}/rules` + : `${apiBasePath}/rules`; + await api.post(createEndpoint, { + roleId, + context: rule.context, + item: rule.item, + view: rule.view, + read: rule.read, + create: rule.create, + update: rule.update, + delete: rule.delete, + }, { headers }); + } + + // Update existing rules + for (const rule of existingRules) { + const original = currentRules.find(r => r.id === rule.id); + if (original && JSON.stringify(original) !== JSON.stringify(rule)) { + const updateEndpoint = isInstanceApi + ? `${apiBasePath}/rules/${rule.id}` + : `${apiBasePath}/rules/${rule.id}`; + await api.put(updateEndpoint, { + view: rule.view, + read: rule.read, + create: rule.create, + update: rule.update, + delete: rule.delete, + }, { headers }); + } + } + + // Refresh rules + await fetchRules(); + + return { success: true }; + } catch (err: any) { + const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Speichern'; + setError(errorMsg); + console.error('Error saving rules:', err); + return { success: false, error: errorMsg }; + } finally { + setSaving(false); + } + }, [roleId, apiBasePath, isInstanceApi, fetchRules, getHeaders]); + + /** + * Get rules grouped by context + */ + const getGroupedRules = useCallback((): GroupedRules => { + return { + DATA: rules.filter(r => r.context === 'DATA'), + UI: rules.filter(r => r.context === 'UI'), + RESOURCE: rules.filter(r => r.context === 'RESOURCE'), + }; + }, [rules]); + + /** + * Update a rule locally (not saved until saveRules is called) + */ + const updateRuleLocally = useCallback((ruleId: string, updates: Partial) => { + setRules(prev => prev.map(r => + r.id === ruleId ? { ...r, ...updates } : r + )); + }, []); + + /** + * Add a rule locally (not saved until saveRules is called) + */ + const addRuleLocally = useCallback((rule: AccessRule) => { + setRules(prev => [...prev, rule]); + }, []); + + /** + * Remove a rule locally (not saved until saveRules is called) + */ + const removeRuleLocally = useCallback((ruleId: string) => { + setRules(prev => prev.filter(r => r.id !== ruleId)); + }, []); + + return { + rules, + loading, + saving, + error, + fetchRules, + saveRules, + getGroupedRules, + updateRuleLocally, + addRuleLocally, + removeRuleLocally, + }; +} + +export default useAccessRules; diff --git a/src/layouts/FeatureLayout.tsx b/src/layouts/FeatureLayout.tsx index 406816b..53e9115 100644 --- a/src/layouts/FeatureLayout.tsx +++ b/src/layouts/FeatureLayout.tsx @@ -96,7 +96,7 @@ export const FeatureLayout: React.FC = () => { {instance?.instanceLabel}
- {instance?.userRole} + {instance?.userRoles?.join(', ') || '-'}
diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index df54211..a94a979 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -48,7 +48,7 @@ const InstanceCard: React.FC = ({ instance, featureLabel }) =
{featureLabel} - {instance.userRole} + {instance.userRoles?.join(', ') || '-'}

{instance.instanceLabel}

{instance.mandateName}

diff --git a/src/pages/FeatureView.tsx b/src/pages/FeatureView.tsx index 76780ee..fd4d153 100644 --- a/src/pages/FeatureView.tsx +++ b/src/pages/FeatureView.tsx @@ -11,13 +11,12 @@ import { useCanViewFeatureView } from '../hooks/useInstancePermissions'; import { getLabel, FEATURE_REGISTRY } from '../types/mandate'; // Trustee Views -import { TrusteeContractsView } from './views/trustee/TrusteeContractsView'; -import { TrusteeOrganisationsView } from './views/trustee/TrusteeOrganisationsView'; +// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation import { TrusteeDocumentsView } from './views/trustee/TrusteeDocumentsView'; import { TrusteePositionsView } from './views/trustee/TrusteePositionsView'; -import { TrusteeRolesView } from './views/trustee/TrusteeRolesView'; -import { TrusteeAccessView } from './views/trustee/TrusteeAccessView'; +import { TrusteePositionDocumentsView } from './views/trustee/TrusteePositionDocumentsView'; import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView'; +import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView'; import styles from './FeatureView.module.css'; @@ -78,12 +77,10 @@ type ViewComponent = React.FC; const VIEW_COMPONENTS: Record> = { trustee: { dashboard: TrusteeDashboardView, - organisations: TrusteeOrganisationsView, - contracts: TrusteeContractsView, documents: TrusteeDocumentsView, positions: TrusteePositionsView, - roles: TrusteeRolesView, - access: TrusteeAccessView, + 'position-documents': TrusteePositionDocumentsView, + 'instance-roles': TrusteeInstanceRolesView, }, chatworkflow: { dashboard: ChatworkflowDashboard, diff --git a/src/pages/admin/AdminFeatureRolesPage.tsx b/src/pages/admin/AdminFeatureRolesPage.tsx index c72fb28..5021dfa 100644 --- a/src/pages/admin/AdminFeatureRolesPage.tsx +++ b/src/pages/admin/AdminFeatureRolesPage.tsx @@ -13,7 +13,8 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; -import { FaPlus, FaSync, FaUserShield, FaCube } from 'react-icons/fa'; +import { AccessRulesEditor } from '../../components/AccessRules'; +import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } from 'react-icons/fa'; import api from '../../api'; import styles from './Admin.module.css'; @@ -47,6 +48,7 @@ export const AdminFeatureRolesPage: React.FC = () => { const [showCreateModal, setShowCreateModal] = useState(false); const [editingRole, setEditingRole] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); + const [permissionsRole, setPermissionsRole] = useState(null); // Load features on mount useEffect(() => { @@ -369,6 +371,14 @@ export const AdminFeatureRolesPage: React.FC = () => { title: 'Rolle löschen', } ]} + customActions={[ + { + id: 'permissions', + icon: , + onClick: (role: FeatureRole) => setPermissionsRole(role), + title: 'Berechtigungen verwalten', + } + ]} onDelete={handleDeleteRole} hookData={{ refetch: fetchRoles, @@ -441,6 +451,39 @@ export const AdminFeatureRolesPage: React.FC = () => {
)} + + {/* Permissions Modal */} + {permissionsRole && ( +
setPermissionsRole(null)}> +
e.stopPropagation()}> +
+

+ + Berechtigungen: {permissionsRole.roleLabel} +

+ +
+
+
+ + Feature: {permissionsRole.featureCode} + Template-Rolle (global) +
+ setPermissionsRole(null)} + /> +
+
+
+ )} ); }; diff --git a/src/pages/views/trustee/TrusteeAccessView.tsx b/src/pages/views/trustee/TrusteeAccessView.tsx deleted file mode 100644 index 4685ea5..0000000 --- a/src/pages/views/trustee/TrusteeAccessView.tsx +++ /dev/null @@ -1,247 +0,0 @@ -/** - * TrusteeAccessView - * - * Zugriffs-Verwaltung für eine Trustee-Instanz. - * Zeigt User-Zuweisungen zu Organisationen mit Rollen. - */ - -import React, { useState, useMemo, useEffect } from 'react'; -import { useTrusteeAccess, useTrusteeAccessOperations, TrusteeAccess } from '../../../hooks/useTrustee'; -import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions'; -import { useTablePermission } from '../../../hooks/useInstancePermissions'; -import { Popup } from '../../../components/UiComponents/Popup/Popup'; -import { TrusteeEditForm, FieldConfig } from './components'; -import styles from './TrusteeViews.module.css'; - -export const TrusteeAccessView: React.FC = () => { - const { items: accessList, loading, error, refetch } = useTrusteeAccess(); - const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteeAccessOperations(); - const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeAccess'); - - // Options für Label-Auflösung und Dropdowns - const { getLabelFast, loading: optionsLoading, loadOptions, getOptions, loadContractsForOrganisation } = useTrusteeOptions(['users', 'organisations', 'roles']); - - // Modal State - const [isModalOpen, setIsModalOpen] = useState(false); - const [editingAccess, setEditingAccess] = useState(null); - const [formError, setFormError] = useState(null); - const [contractOptions, setContractOptions] = useState>([]); - - // Lade Contracts wenn Organisation ausgewählt - const handleOrganisationChange = async (organisationId: string) => { - if (organisationId) { - const contracts = await loadContractsForOrganisation(organisationId); - setContractOptions(contracts); - } else { - setContractOptions([]); - } - }; - - // Feld-Konfiguration für das Formular - const fields: FieldConfig[] = useMemo(() => [ - { - key: 'userId', - label: 'Benutzer', - type: 'enum', - required: true, - optionsReference: 'users', - }, - { - key: 'organisationId', - label: 'Organisation', - type: 'enum', - required: true, - optionsReference: 'organisations', - }, - { - key: 'roleId', - label: 'Rolle', - type: 'enum', - required: true, - optionsReference: 'roles', - }, - { - key: 'contractId', - label: 'Vertrag (optional)', - type: 'enum', - required: false, - options: contractOptions, - dependsOn: 'organisationId', - helpText: 'Leer = Zugriff auf alle Verträge der Organisation', - }, - ], [contractOptions]); - - if (loading || optionsLoading) { - return
Lade Zugriffe...
; - } - - if (error) { - return
Fehler: {error}
; - } - - const onDelete = async (accessId: string) => { - if (window.confirm('Zugriff wirklich entfernen?')) { - const success = await handleDelete(accessId); - if (success) { - refetch(); - } - } - }; - - const onEdit = async (access: TrusteeAccess) => { - setEditingAccess(access); - setFormError(null); - // Lade Contracts für die Organisation - if (access.organisationId) { - const contracts = await loadContractsForOrganisation(access.organisationId); - setContractOptions(contracts); - } - setIsModalOpen(true); - }; - - const onCreate = () => { - setEditingAccess(null); - setFormError(null); - setContractOptions([]); - setIsModalOpen(true); - }; - - const onCloseModal = () => { - setIsModalOpen(false); - setEditingAccess(null); - setFormError(null); - setContractOptions([]); - }; - - const onSave = async (data: Partial) => { - setFormError(null); - - // Konvertiere leeren String zu null für contractId - const processedData = { - ...data, - contractId: data.contractId || null, - }; - - try { - if (editingAccess) { - const result = await handleUpdate(editingAccess.id, processedData); - if (!result.success) { - setFormError(result.error || 'Fehler beim Aktualisieren'); - return; - } - } else { - const result = await handleCreate(processedData); - if (!result.success) { - setFormError(result.error || 'Fehler beim Erstellen'); - return; - } - } - - onCloseModal(); - refetch(); - } catch (err: any) { - setFormError(err.message || 'Ein Fehler ist aufgetreten'); - } - }; - - return ( -
- {/* Toolbar */} -
- {canCreate && ( - - )} - -
- - {/* Tabelle */} - {accessList.length === 0 ? ( -
-

Keine Zugriffe definiert.

-
- ) : ( - - - - - - - - - - - - {accessList.map((access) => ( - - - - - - - - ))} - -
BenutzerOrganisationRolleVertragAktionen
{getLabelFast('users', access.userId)}{getLabelFast('organisations', access.organisationId)} - - {getLabelFast('roles', access.roleId)} - - - {access.contractId ? ( - getLabelFast('contracts', access.contractId) - ) : ( - Alle - )} - - {canUpdate && ( - - )} - {canDelete && ( - - )} -
- )} - - {/* Create/Edit Modal */} - - {formError && ( -
- {formError} -
- )} - - initialData={editingAccess || {}} - fields={fields} - onSave={onSave} - onCancel={onCloseModal} - isSaving={creatingItem} - isEdit={!!editingAccess} - saveLabel={editingAccess ? 'Aktualisieren' : 'Erstellen'} - /> -
-
- ); -}; - -export default TrusteeAccessView; diff --git a/src/pages/views/trustee/TrusteeContractsView.tsx b/src/pages/views/trustee/TrusteeContractsView.tsx deleted file mode 100644 index b28ea91..0000000 --- a/src/pages/views/trustee/TrusteeContractsView.tsx +++ /dev/null @@ -1,209 +0,0 @@ -/** - * TrusteeContractsView - * - * Vertrags-Verwaltung für eine Trustee-Instanz. - * Zeigt Kundenverträge mit Organisation-Zuordnung. - */ - -import React, { useState, useMemo } from 'react'; -import { useTrusteeContracts, useTrusteeContractOperations, TrusteeContract } from '../../../hooks/useTrustee'; -import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions'; -import { useTablePermission } from '../../../hooks/useInstancePermissions'; -import { Popup } from '../../../components/UiComponents/Popup/Popup'; -import { TrusteeEditForm, FieldConfig } from './components'; -import styles from './TrusteeViews.module.css'; - -export const TrusteeContractsView: React.FC = () => { - const { items: contracts, loading, error, refetch } = useTrusteeContracts(); - const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteeContractOperations(); - const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeContract'); - - // Options für Label-Auflösung - const { getLabelFast, loading: optionsLoading } = useTrusteeOptions(['organisations']); - - // Modal State - const [isModalOpen, setIsModalOpen] = useState(false); - const [editingContract, setEditingContract] = useState(null); - const [formError, setFormError] = useState(null); - - // Feld-Konfiguration für das Formular - const fields: FieldConfig[] = useMemo(() => [ - { - key: 'organisationId', - label: 'Organisation', - type: 'enum', - required: true, - optionsReference: 'organisations', - editable: !editingContract, // Nicht änderbar nach Erstellung - helpText: editingContract ? 'Organisation kann nicht geändert werden' : undefined, - }, - { - key: 'label', - label: 'Bezeichnung', - type: 'string', - required: true, - placeholder: 'z.B. Kunde AG 2026', - }, - { - key: 'enabled', - label: 'Status', - type: 'boolean', - helpText: 'Vertrag ist aktiv', - }, - ], [editingContract]); - - if (loading || optionsLoading) { - return
Lade Verträge...
; - } - - if (error) { - return
Fehler: {error}
; - } - - const onDelete = async (contractId: string) => { - if (window.confirm('Vertrag wirklich löschen?')) { - const success = await handleDelete(contractId); - if (success) { - refetch(); - } - } - }; - - const onEdit = (contract: TrusteeContract) => { - setEditingContract(contract); - setFormError(null); - setIsModalOpen(true); - }; - - const onCreate = () => { - setEditingContract(null); - setFormError(null); - setIsModalOpen(true); - }; - - const onCloseModal = () => { - setIsModalOpen(false); - setEditingContract(null); - setFormError(null); - }; - - const onSave = async (data: Partial) => { - setFormError(null); - - try { - if (editingContract) { - // Bei Update: organisationId nicht mitsenden (ist immutable) - const { organisationId, ...updateData } = data; - const result = await handleUpdate(editingContract.id, updateData); - if (!result.success) { - setFormError(result.error || 'Fehler beim Aktualisieren'); - return; - } - } else { - const result = await handleCreate(data); - if (!result.success) { - setFormError(result.error || 'Fehler beim Erstellen'); - return; - } - } - - onCloseModal(); - refetch(); - } catch (err: any) { - setFormError(err.message || 'Ein Fehler ist aufgetreten'); - } - }; - - return ( -
- {/* Toolbar */} -
- {canCreate && ( - - )} - -
- - {/* Tabelle */} - {contracts.length === 0 ? ( -
-

Keine Verträge vorhanden.

-
- ) : ( - - - - - - - - - - - {contracts.map((contract) => ( - - - - - - - ))} - -
BezeichnungOrganisationStatusAktionen
{contract.label}{getLabelFast('organisations', contract.organisationId)} - - {contract.enabled ? 'Aktiv' : 'Inaktiv'} - - - {canUpdate && ( - - )} - {canDelete && ( - - )} -
- )} - - {/* Create/Edit Modal */} - - {formError && ( -
- {formError} -
- )} - - initialData={editingContract || { enabled: true }} - fields={fields} - onSave={onSave} - onCancel={onCloseModal} - isSaving={creatingItem} - isEdit={!!editingContract} - saveLabel={editingContract ? 'Aktualisieren' : 'Erstellen'} - /> -
-
- ); -}; - -export default TrusteeContractsView; diff --git a/src/pages/views/trustee/TrusteeDashboardView.tsx b/src/pages/views/trustee/TrusteeDashboardView.tsx index 384c1ea..af8a966 100644 --- a/src/pages/views/trustee/TrusteeDashboardView.tsx +++ b/src/pages/views/trustee/TrusteeDashboardView.tsx @@ -1,53 +1,73 @@ /** * TrusteeDashboardView * - * Übersicht/Dashboard für eine Trustee-Instanz + * Übersicht/Dashboard für eine Trustee-Instanz. + * Zeigt Statistiken über Positionen, Dokumente und Verknüpfungen. */ import React from 'react'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; -import { useTrusteeOrganisations } from '../../../hooks/useTrustee'; -import { useTrusteeContracts } from '../../../hooks/useTrustee'; +import { useTrusteePositions, useTrusteeDocuments, useTrusteePositionDocuments } from '../../../hooks/useTrustee'; import styles from './TrusteeViews.module.css'; export const TrusteeDashboardView: React.FC = () => { const { instance } = useCurrentInstance(); - const { items: organisations, loading: orgsLoading } = useTrusteeOrganisations(); - const { items: contracts, loading: contractsLoading } = useTrusteeContracts(); + const { items: positions, loading: posLoading } = useTrusteePositions(); + const { items: documents, loading: docsLoading } = useTrusteeDocuments(); + const { items: links, loading: linksLoading } = useTrusteePositionDocuments(); - const isLoading = orgsLoading || contractsLoading; + const isLoading = posLoading || docsLoading || linksLoading; return (
- {/* Organisationen Card */} + {/* Positionen Card */}
-
🏢
+
📊
- {isLoading ? '...' : organisations.length} + {isLoading ? '...' : positions.length}
-
Organisationen
+
Positionen
- {/* Verträge Card */} + {/* Dokumente Card */}
📄
- {isLoading ? '...' : contracts.length} + {isLoading ? '...' : documents.length}
-
Verträge
+
Dokumente
- {/* Rolle Card */} + {/* Verknüpfungen Card */} +
+
🔗
+
+
+ {isLoading ? '...' : links.length} +
+
Zuordnungen
+
+
+ + {/* Rollen Card */}
👤
-
{instance?.userRole || '-'}
-
Deine Rolle
+
+ {instance?.userRoles?.length ? ( + instance.userRoles.map((role, idx) => ( +
{role}
+ )) + ) : '-'} +
+
+ {(instance?.userRoles?.length || 0) === 1 ? 'Deine Rolle' : 'Deine Rollen'} +
diff --git a/src/pages/views/trustee/TrusteeDocumentsView.tsx b/src/pages/views/trustee/TrusteeDocumentsView.tsx index 664b46d..c816ceb 100644 --- a/src/pages/views/trustee/TrusteeDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteeDocumentsView.tsx @@ -2,154 +2,129 @@ * TrusteeDocumentsView * * Dokument-Verwaltung für eine Trustee-Instanz. - * Zeigt Belege und Dokumente mit Vertragszuordnung. + * Verwendet FormGeneratorTable für konsistentes UI. */ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { useTrusteeDocuments, useTrusteeDocumentOperations, TrusteeDocument } from '../../../hooks/useTrustee'; -import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions'; -import { useTablePermission } from '../../../hooks/useInstancePermissions'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; -import { Popup } from '../../../components/UiComponents/Popup/Popup'; -import { TrusteeEditForm, FieldConfig } from './components'; +import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable'; +import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm'; +import { FaSync, FaFileAlt, FaDownload } from 'react-icons/fa'; import api from '../../../api'; -import styles from './TrusteeViews.module.css'; +import styles from '../../admin/Admin.module.css'; export const TrusteeDocumentsView: React.FC = () => { const instanceId = useInstanceId(); - const { items: documents, loading, error, refetch } = useTrusteeDocuments(); - const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteeDocumentOperations(); - const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeDocument'); - // Options für Label-Auflösung - const { getLabelFast, loading: optionsLoading, loadContractsForOrganisation } = useTrusteeOptions(['organisations', 'contracts']); - - // Modal State - const [isModalOpen, setIsModalOpen] = useState(false); - const [editingDoc, setEditingDoc] = useState(null); - const [formError, setFormError] = useState(null); - const [downloading, setDownloading] = useState(null); - const [contractOptions, setContractOptions] = useState>([]); - - // MIME-Type Options - const mimeTypeOptions = [ - { value: 'application/pdf', label: 'PDF' }, - { value: 'image/jpeg', label: 'JPEG' }, - { value: 'image/png', label: 'PNG' }, - { value: 'application/octet-stream', label: 'Andere' }, - ]; - - // Feld-Konfiguration für das Formular - const fields: FieldConfig[] = useMemo(() => [ - { - key: 'organisationId', - label: 'Organisation', - type: 'enum', - required: true, - optionsReference: 'organisations', - }, - { - key: 'contractId', - label: 'Vertrag', - type: 'enum', - required: true, - options: contractOptions, - dependsOn: 'organisationId', - }, - { - key: 'documentName', - label: 'Dokumentname', - type: 'string', - required: true, - placeholder: 'z.B. Rechnung_2026.pdf', - }, - { - key: 'documentMimeType', - label: 'Dateityp', - type: 'enum', - required: true, - options: mimeTypeOptions, - }, - ], [contractOptions]); - - if (loading || optionsLoading) { - return
Lade Dokumente...
; - } - - if (error) { - return
Fehler: {error}
; - } - - const onDelete = async (docId: string) => { - if (window.confirm('Dokument wirklich löschen?')) { - const success = await handleDelete(docId); - if (success) { + // Entity hook + const { + items: documents, + attributes, + permissions, + pagination, + loading, + error, + refetch, + fetchById, + updateOptimistically, + removeOptimistically, + } = useTrusteeDocuments(); + + // Operations hook + const { + handleDelete, + handleCreate, + handleUpdate, + deletingItems, + } = useTrusteeDocumentOperations(); + + // Modal state + const [editingDocument, setEditingDocument] = useState(null); + const [isCreateMode, setIsCreateMode] = useState(false); + const [downloadingId, setDownloadingId] = useState(null); + + // Initial fetch + useEffect(() => { + if (instanceId) { + refetch(); + } + }, [instanceId]); + + // Generate columns from attributes + const columns = useMemo(() => { + return (attributes || []).map(attr => ({ + key: attr.name, + label: attr.label || attr.name, + type: attr.type as any, + sortable: attr.sortable !== false, + filterable: attr.filterable !== false, + searchable: attr.searchable !== false, + width: attr.width || 150, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + })); + }, [attributes]); + + // Check permissions + const canCreate = permissions?.create !== 'n'; + const canUpdate = permissions?.update !== 'n'; + const canDelete = permissions?.delete !== 'n'; + + // Handle edit click + const handleEditClick = async (doc: TrusteeDocument) => { + const fullDoc = await fetchById(doc.id); + if (fullDoc) { + setEditingDocument(fullDoc); + setIsCreateMode(false); + } + }; + + // Handle create click + const handleCreateClick = () => { + setEditingDocument(null); + setIsCreateMode(true); + }; + + // Handle form submit + const handleFormSubmit = async (data: Partial) => { + if (isCreateMode) { + const result = await handleCreate(data); + if (result.success) { + setIsCreateMode(false); + refetch(); + } + } else if (editingDocument) { + const result = await handleUpdate(editingDocument.id, data); + if (result.success) { + setEditingDocument(null); refetch(); } } }; - - const onEdit = async (doc: TrusteeDocument) => { - setEditingDoc(doc); - setFormError(null); - // Lade Contracts für die Organisation - if (doc.organisationId) { - const contracts = await loadContractsForOrganisation(doc.organisationId); - setContractOptions(contracts); - } - setIsModalOpen(true); - }; - - const onCreate = () => { - setEditingDoc(null); - setFormError(null); - setContractOptions([]); - setIsModalOpen(true); - }; - - const onCloseModal = () => { - setIsModalOpen(false); - setEditingDoc(null); - setFormError(null); - setContractOptions([]); - }; - - const onSave = async (data: Partial) => { - setFormError(null); - - try { - if (editingDoc) { - const result = await handleUpdate(editingDoc.id, data); - if (!result.success) { - setFormError(result.error || 'Fehler beim Aktualisieren'); - return; - } - } else { - const result = await handleCreate(data); - if (!result.success) { - setFormError(result.error || 'Fehler beim Erstellen'); - return; - } + + // Handle delete + const handleDeleteDoc = async (doc: TrusteeDocument) => { + if (window.confirm(`Dokument "${doc.documentName}" wirklich löschen?`)) { + removeOptimistically(doc.id); + const success = await handleDelete(doc.id); + if (!success) { + refetch(); // Revert on error } - - onCloseModal(); - refetch(); - } catch (err: any) { - setFormError(err.message || 'Ein Fehler ist aufgetreten'); } }; - - const onDownload = async (doc: TrusteeDocument) => { + + // Handle download + const handleDownload = async (doc: TrusteeDocument) => { if (!instanceId) return; - setDownloading(doc.id); + setDownloadingId(doc.id); try { const response = await api.get( `/api/trustee/${instanceId}/documents/${doc.id}/data`, { responseType: 'blob' } ); - // Blob-Download const blob = new Blob([response.data], { type: doc.documentMimeType }); const url = window.URL.createObjectURL(blob); const link = document.createElement('a'); @@ -163,112 +138,174 @@ export const TrusteeDocumentsView: React.FC = () => { console.error('Download error:', err); alert('Fehler beim Herunterladen des Dokuments.'); } finally { - setDownloading(null); + setDownloadingId(null); } }; - - // MIME-Type zu lesbarem Text - const getMimeTypeLabel = (mimeType: string) => { - const found = mimeTypeOptions.find(o => o.value === mimeType); - return found?.label || mimeType?.split('/')[1]?.toUpperCase() || 'Unbekannt'; + + // Close modal + const handleCloseModal = () => { + setEditingDocument(null); + setIsCreateMode(false); }; - - return ( -
- {/* Toolbar */} -
- {canCreate && ( - - )} - -
- - {/* Tabelle */} - {documents.length === 0 ? ( -
-

Keine Dokumente vorhanden.

- ) : ( - - - - - - - - - - - {documents.map((doc) => ( - - - - - - - ))} - -
NameTypVertragAktionen
{doc.documentName} - - {getMimeTypeLabel(doc.documentMimeType)} - - {getLabelFast('contracts', doc.contractId)} - - {canUpdate && ( - - )} - {canDelete && ( - - )} -
- )} - - {/* Create/Edit Modal */} - - {formError && ( -
- {formError} +
+ ); + } + + return ( +
+
+
+

Belege und Dokumente verwalten

+
+
+ + {canCreate && ( + + )} +
+
+ +
+ {loading && (!documents || documents.length === 0) ? ( +
+
+ Lade Dokumente...
+ ) : !documents || documents.length === 0 ? ( +
+ +

Keine Dokumente vorhanden

+

+ Erstellen Sie ein neues Dokument, um zu beginnen. +

+ {canCreate && ( + + )} +
+ ) : ( + deletingItems.has(row.id), + }] : []), + ]} + customActions={[ + { + id: 'download', + icon: , + onClick: handleDownload, + title: 'Herunterladen', + loading: (row: TrusteeDocument) => downloadingId === row.id, + }, + ]} + onDelete={handleDeleteDoc} + hookData={{ + refetch, + permissions, + pagination, + handleDelete, + handleInlineUpdate, + updateOptimistically, + }} + emptyMessage="Keine Dokumente gefunden" + /> )} - - initialData={editingDoc || { documentMimeType: 'application/pdf' }} - fields={fields} - onSave={onSave} - onCancel={onCloseModal} - isSaving={creatingItem} - isEdit={!!editingDoc} - saveLabel={editingDoc ? 'Aktualisieren' : 'Erstellen'} - /> - +
+ + {/* Create/Edit Modal */} + {(editingDocument || isCreateMode) && ( +
+
e.stopPropagation()}> +
+

+ {isCreateMode ? 'Neues Dokument' : 'Dokument bearbeiten'} +

+ +
+
+ {formAttributes.length === 0 ? ( +
+
+ Lade Formular... +
+ ) : ( + + )} +
+
+
+ )}
); }; diff --git a/src/pages/views/trustee/TrusteeInstanceRolesView.tsx b/src/pages/views/trustee/TrusteeInstanceRolesView.tsx new file mode 100644 index 0000000..8d63f2b --- /dev/null +++ b/src/pages/views/trustee/TrusteeInstanceRolesView.tsx @@ -0,0 +1,180 @@ +/** + * TrusteeInstanceRolesView + * + * Verwaltung der instanz-spezifischen Rollen und deren Berechtigungen. + * Nur für Feature-Admins sichtbar (benötigt instance-roles.manage Permission). + * + * Diese View erlaubt das Anpassen der AccessRules für die Rollen dieser + * spezifischen Trustee-Instanz. + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; +import { AccessRulesEditor } from '../../../components/AccessRules'; +import { FaUserShield, FaShieldAlt, FaSync, FaChevronDown, FaChevronRight } from 'react-icons/fa'; +import api from '../../../api'; +import styles from './TrusteeViews.module.css'; + +interface InstanceRole { + id: string; + roleLabel: string; + description?: { [key: string]: string }; + featureCode: string; + mandateId: string; + featureInstanceId: string; + isSystemRole?: boolean; +} + +export const TrusteeInstanceRolesView: React.FC = () => { + const { instance } = useCurrentInstance(); + const [roles, setRoles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedRoleId, setExpandedRoleId] = useState(null); + + // Get display text from multilingual object + const getTextValue = (value: string | { [key: string]: string } | undefined): string => { + if (!value) return ''; + if (typeof value === 'string') return value; + return value.de || value.en || Object.values(value)[0] || ''; + }; + + // Load instance roles + const fetchRoles = useCallback(async () => { + if (!instance?.id || !instance?.mandateId) return; + + setLoading(true); + setError(null); + + try { + const response = await api.get(`/api/trustee/${instance.id}/instance-roles`, { + headers: { 'X-Mandate-Id': instance.mandateId } + }); + const rolesList = response.data?.items || response.data || []; + setRoles(Array.isArray(rolesList) ? rolesList : []); + } catch (err: any) { + const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Laden der Rollen'; + setError(errorMsg); + console.error('Error loading instance roles:', err); + } finally { + setLoading(false); + } + }, [instance?.id, instance?.mandateId]); + + useEffect(() => { + fetchRoles(); + }, [fetchRoles]); + + // Toggle role expansion + const toggleRole = (roleId: string) => { + setExpandedRoleId(prev => prev === roleId ? null : roleId); + }; + + if (!instance) { + return ( +
+
Keine Feature-Instanz ausgewählt
+
+ ); + } + + if (loading) { + return ( +
+
Lade Instanz-Rollen...
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

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

+ + Instanz-Rollen & Berechtigungen +

+

+ Verwalten Sie die Berechtigungen für die Rollen dieser Trustee-Instanz +

+
+
+ +
+
+ +
+ + + Diese Rollen wurden von den Feature-Templates kopiert. + Änderungen hier gelten nur für diese Instanz. + +
+ + {roles.length === 0 ? ( +
+ +

Keine Instanz-Rollen gefunden

+

+ Instanz-Rollen werden automatisch erstellt, wenn Benutzer dieser Instanz zugewiesen werden. +

+
+ ) : ( +
+ {roles.map(role => ( +
+
toggleRole(role.id)} + > +
+ + {expandedRoleId === role.id ? : } + + {role.roleLabel} + + {getTextValue(role.description)} + +
+
+ {role.isSystemRole && ( + System + )} +
+
+ + {expandedRoleId === role.id && ( +
+ +
+ )} +
+ ))} +
+ )} +
+ ); +}; + +export default TrusteeInstanceRolesView; diff --git a/src/pages/views/trustee/TrusteeOrganisationsView.tsx b/src/pages/views/trustee/TrusteeOrganisationsView.tsx deleted file mode 100644 index 0c80e63..0000000 --- a/src/pages/views/trustee/TrusteeOrganisationsView.tsx +++ /dev/null @@ -1,222 +0,0 @@ -/** - * TrusteeOrganisationsView - * - * Organisations-Verwaltung für eine Trustee-Instanz. - * Zeigt Kunden-Organisationen des Treuhandbüros. - */ - -import React, { useState, useMemo } from 'react'; -import { useTrusteeOrganisations, useTrusteeOrganisationOperations, TrusteeOrganisation } from '../../../hooks/useTrustee'; -import { useTablePermission } from '../../../hooks/useInstancePermissions'; -import { Popup } from '../../../components/UiComponents/Popup/Popup'; -import { TrusteeEditForm, FieldConfig } from './components'; -import styles from './TrusteeViews.module.css'; - -export const TrusteeOrganisationsView: React.FC = () => { - const { items: organisations, loading, error, refetch, generateCreateFieldsFromAttributes, generateEditFieldsFromAttributes, ensureAttributesLoaded } = useTrusteeOrganisations(); - const { handleDelete, handleCreate, handleUpdate, deletingItems, createError, updateError, creatingItem } = useTrusteeOrganisationOperations(); - const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeOrganisation'); - - // Modal State - const [isModalOpen, setIsModalOpen] = useState(false); - const [editingOrg, setEditingOrg] = useState(null); - const [formError, setFormError] = useState(null); - - // Feld-Konfiguration für das Formular - const fields: FieldConfig[] = useMemo(() => [ - { - key: 'id', - label: 'ID', - type: 'string', - required: true, - editable: !editingOrg, // Nur bei Create editierbar - placeholder: 'z.B. kunde-ag', - helpText: 'Eindeutige ID (alphanumerisch, Bindestrich, Unterstrich)', - }, - { - key: 'label', - label: 'Bezeichnung', - type: 'string', - required: true, - placeholder: 'z.B. Kunde AG', - }, - { - key: 'enabled', - label: 'Status', - type: 'boolean', - helpText: 'Organisation ist aktiv', - }, - ], [editingOrg]); - - if (loading) { - return
Lade Organisationen...
; - } - - if (error) { - return
Fehler: {error}
; - } - - const onDelete = async (orgId: string) => { - if (window.confirm('Organisation wirklich löschen?')) { - const success = await handleDelete(orgId); - if (success) { - refetch(); - } - } - }; - - const onEdit = (org: TrusteeOrganisation) => { - setEditingOrg(org); - setFormError(null); - setIsModalOpen(true); - }; - - const onCreate = () => { - setEditingOrg(null); - setFormError(null); - setIsModalOpen(true); - }; - - const onCloseModal = () => { - setIsModalOpen(false); - setEditingOrg(null); - setFormError(null); - }; - - const onSave = async (data: Partial) => { - setFormError(null); - - try { - if (editingOrg) { - // Update - const result = await handleUpdate(editingOrg.id, data); - if (!result.success) { - setFormError(result.error || 'Fehler beim Aktualisieren'); - return; - } - } else { - // Create - const result = await handleCreate(data); - if (!result.success) { - setFormError(result.error || 'Fehler beim Erstellen'); - return; - } - } - - onCloseModal(); - refetch(); - } catch (err: any) { - setFormError(err.message || 'Ein Fehler ist aufgetreten'); - } - }; - - // Validierung - const validateOrganisation = (data: Partial): Record | null => { - const errors: Record = {}; - - // ID-Format prüfen (nur bei Create) - if (!editingOrg && data.id) { - if (data.id.length < 3 || data.id.length > 50) { - errors.id = 'ID muss zwischen 3 und 50 Zeichen lang sein'; - } else if (!/^[a-zA-Z0-9_-]+$/.test(data.id)) { - errors.id = 'ID darf nur Buchstaben, Zahlen, Bindestrich und Unterstrich enthalten'; - } - } - - return Object.keys(errors).length > 0 ? errors : null; - }; - - return ( -
- {/* Toolbar */} -
- {canCreate && ( - - )} - -
- - {/* Tabelle */} - {organisations.length === 0 ? ( -
-

Keine Organisationen vorhanden.

-
- ) : ( - - - - - - - - - - - {organisations.map((org) => ( - - - - - - - ))} - -
IDBezeichnungStatusAktionen
{org.id}{org.label} - - {org.enabled ? 'Aktiv' : 'Inaktiv'} - - - {canUpdate && ( - - )} - {canDelete && ( - - )} -
- )} - - {/* Create/Edit Modal */} - - {formError && ( -
- {formError} -
- )} - - initialData={editingOrg || { enabled: true }} - fields={fields} - onSave={onSave} - onCancel={onCloseModal} - isSaving={creatingItem} - validate={validateOrganisation} - isEdit={!!editingOrg} - saveLabel={editingOrg ? 'Aktualisieren' : 'Erstellen'} - /> -
-
- ); -}; - -export default TrusteeOrganisationsView; diff --git a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx index cf58187..ab804fb 100644 --- a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx @@ -2,203 +2,230 @@ * TrusteePositionDocumentsView * * Verknüpfungs-Verwaltung zwischen Positionen und Dokumenten. - * Ermöglicht das Zuweisen von Belegen zu Buchungspositionen. + * Verwendet FormGeneratorTable für konsistentes UI. */ -import React, { useState, useMemo } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations, TrusteePositionDocument } from '../../../hooks/useTrustee'; -import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions'; -import { useTablePermission } from '../../../hooks/useInstancePermissions'; -import { Popup } from '../../../components/UiComponents/Popup/Popup'; -import { TrusteeEditForm, FieldConfig } from './components'; -import styles from './TrusteeViews.module.css'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable'; +import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm'; +import { FaSync, FaLink } from 'react-icons/fa'; +import styles from '../../admin/Admin.module.css'; export const TrusteePositionDocumentsView: React.FC = () => { - const { items: links, loading, error, refetch } = useTrusteePositionDocuments(); - const { handleDelete, handleCreate, deletingItems, creatingItem } = useTrusteePositionDocumentOperations(); - const { canCreate, canDelete } = useTablePermission('TrusteePositionDocument'); + const instanceId = useInstanceId(); - // Options für Label-Auflösung - const { getLabelFast, loading: optionsLoading, loadContractsForOrganisation } = useTrusteeOptions(['organisations', 'contracts', 'positions', 'documents']); - - // Modal State - const [isModalOpen, setIsModalOpen] = useState(false); - const [formError, setFormError] = useState(null); - const [contractOptions, setContractOptions] = useState>([]); - - // Feld-Konfiguration für das Formular - const fields: FieldConfig[] = useMemo(() => [ - { - key: 'organisationId', - label: 'Organisation', - type: 'enum', - required: true, - optionsReference: 'organisations', - }, - { - key: 'contractId', - label: 'Vertrag', - type: 'enum', - required: true, - options: contractOptions, - dependsOn: 'organisationId', - }, - { - key: 'positionId', - label: 'Position', - type: 'enum', - required: true, - optionsReference: 'positions', - helpText: 'Die Buchungsposition, der ein Beleg zugewiesen werden soll', - }, - { - key: 'documentId', - label: 'Dokument', - type: 'enum', - required: true, - optionsReference: 'documents', - helpText: 'Der Beleg, der der Position zugewiesen werden soll', - }, - ], [contractOptions]); - - if (loading || optionsLoading) { - return
Lade Verknüpfungen...
; - } - - if (error) { - return
Fehler: {error}
; - } - - const onDelete = async (linkId: string) => { - if (window.confirm('Verknüpfung wirklich entfernen?')) { - const success = await handleDelete(linkId); - if (success) { - refetch(); - } - } - }; - - const onCreate = () => { - setFormError(null); - setContractOptions([]); - setIsModalOpen(true); - }; - - const onCloseModal = () => { - setIsModalOpen(false); - setFormError(null); - setContractOptions([]); - }; - - const onSave = async (data: Partial) => { - setFormError(null); - - try { - const result = await handleCreate(data); - if (!result.success) { - setFormError(result.error || 'Fehler beim Erstellen'); - return; - } - - onCloseModal(); + // Entity hook + const { + items: links, + attributes, + permissions, + pagination, + loading, + error, + refetch, + removeOptimistically, + } = useTrusteePositionDocuments(); + + // Operations hook + const { + handleDelete, + handleCreate, + deletingItems, + creatingItem, + } = useTrusteePositionDocumentOperations(); + + // Modal state + const [isCreateMode, setIsCreateMode] = useState(false); + + // Initial fetch + useEffect(() => { + if (instanceId) { + refetch(); + } + }, [instanceId]); + + // Generate columns from attributes + const columns = useMemo(() => { + return (attributes || []).map(attr => ({ + key: attr.name, + label: attr.label || attr.name, + type: attr.type as any, + sortable: attr.sortable !== false, + filterable: attr.filterable !== false, + searchable: attr.searchable !== false, + width: attr.width || 150, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + })); + }, [attributes]); + + // Check permissions + const canCreate = permissions?.create !== 'n'; + const canDelete = permissions?.delete !== 'n'; + + // Handle create click + const handleCreateClick = () => { + setIsCreateMode(true); + }; + + // Handle form submit + const handleFormSubmit = async (data: Partial) => { + const result = await handleCreate(data); + if (result.success) { + setIsCreateMode(false); refetch(); - } catch (err: any) { - setFormError(err.message || 'Ein Fehler ist aufgetreten'); } }; - - // Gruppiere nach Position für bessere Übersicht - const groupedByPosition = useMemo(() => { - const grouped: Record = {}; - links.forEach(link => { - if (!grouped[link.positionId]) { - grouped[link.positionId] = []; + + // Handle delete + const handleDeleteLink = async (link: TrusteePositionDocument) => { + if (window.confirm('Verknüpfung wirklich entfernen?')) { + removeOptimistically(link.id); + const success = await handleDelete(link.id); + if (!success) { + refetch(); // Revert on error } - grouped[link.positionId].push(link); - }); - return grouped; - }, [links]); - - return ( -
- {/* Toolbar */} -
- {canCreate && ( - - )} - -
- - {/* Info */} -
- Hier verknüpfen Sie Belege (Dokumente) mit Buchungspositionen. -
- - {/* Tabelle */} - {links.length === 0 ? ( -
-

Keine Verknüpfungen vorhanden.

- ) : ( - - - - - - - - - - - {links.map((link) => ( - - - - - - - ))} - -
PositionDokumentVertragAktionen
{getLabelFast('positions', link.positionId)}{getLabelFast('documents', link.documentId)}{getLabelFast('contracts', link.contractId)} - {canDelete && ( - - )} -
- )} - - {/* Create Modal */} - - {formError && ( -
- {formError} +
+ ); + } + + return ( +
+
+
+

Belege mit Buchungspositionen verknüpfen

+
+
+ + {canCreate && ( + + )} +
+
+ +
+ {loading && (!links || links.length === 0) ? ( +
+
+ Lade Verknüpfungen...
+ ) : !links || links.length === 0 ? ( +
+ +

Keine Verknüpfungen vorhanden

+

+ Verknüpfen Sie Belege mit Buchungspositionen. +

+ {canCreate && ( + + )} +
+ ) : ( + deletingItems.has(row.id), + }] : []), + ]} + onDelete={handleDeleteLink} + hookData={{ + refetch, + permissions, + pagination, + handleDelete, + }} + emptyMessage="Keine Verknüpfungen gefunden" + /> )} - - initialData={{}} - fields={fields} - onSave={onSave} - onCancel={onCloseModal} - isSaving={creatingItem} - isEdit={false} - saveLabel="Verknüpfung erstellen" - /> - +
+ + {/* Create Modal */} + {isCreateMode && ( +
+
e.stopPropagation()}> +
+

Neue Verknüpfung erstellen

+ +
+
+ {formAttributes.length === 0 ? ( +
+
+ Lade Formular... +
+ ) : ( + + )} +
+
+
+ )}
); }; diff --git a/src/pages/views/trustee/TrusteePositionsView.tsx b/src/pages/views/trustee/TrusteePositionsView.tsx index 2b87571..083e970 100644 --- a/src/pages/views/trustee/TrusteePositionsView.tsx +++ b/src/pages/views/trustee/TrusteePositionsView.tsx @@ -2,322 +2,285 @@ * TrusteePositionsView * * Positions-Verwaltung für eine Trustee-Instanz. - * Zeigt Buchungspositionen (Speseneinträge) mit Beträgen. + * Verwendet FormGeneratorTable für konsistentes UI. */ -import React, { useState, useMemo, useCallback } from 'react'; +import React, { useState, useMemo, useEffect } from 'react'; import { useTrusteePositions, useTrusteePositionOperations, TrusteePosition } from '../../../hooks/useTrustee'; -import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions'; -import { useTablePermission } from '../../../hooks/useInstancePermissions'; -import { Popup } from '../../../components/UiComponents/Popup/Popup'; -import { TrusteeEditForm, FieldConfig } from './components'; -import styles from './TrusteeViews.module.css'; +import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable'; +import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm'; +import { FaSync, FaReceipt } from 'react-icons/fa'; +import styles from '../../admin/Admin.module.css'; export const TrusteePositionsView: React.FC = () => { - const { items: positions, loading, error, refetch } = useTrusteePositions(); - const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteePositionOperations(); - const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteePosition'); + const instanceId = useInstanceId(); - // Options für Label-Auflösung - const { getLabelFast, loading: optionsLoading, loadContractsForOrganisation } = useTrusteeOptions(['organisations', 'contracts']); - - // Modal State - const [isModalOpen, setIsModalOpen] = useState(false); + // Entity hook + const { + items: positions, + attributes, + permissions, + pagination, + loading, + error, + refetch, + fetchById, + updateOptimistically, + removeOptimistically, + } = useTrusteePositions(); + + // Operations hook + const { + handleDelete, + handleCreate, + handleUpdate, + deletingItems, + creatingItem, + } = useTrusteePositionOperations(); + + // Modal state const [editingPosition, setEditingPosition] = useState(null); - const [formError, setFormError] = useState(null); - const [contractOptions, setContractOptions] = useState>([]); - - // Währungs-Options - const currencyOptions = [ - { value: 'CHF', label: 'CHF' }, - { value: 'EUR', label: 'EUR' }, - { value: 'USD', label: 'USD' }, - { value: 'GBP', label: 'GBP' }, - ]; - - // Feld-Konfiguration für das Formular - const fields: FieldConfig[] = useMemo(() => [ - { - key: 'organisationId', - label: 'Organisation', - type: 'enum', - required: true, - optionsReference: 'organisations', - }, - { - key: 'contractId', - label: 'Vertrag', - type: 'enum', - required: true, - options: contractOptions, - dependsOn: 'organisationId', - }, - { - key: 'valuta', - label: 'Valutadatum', - type: 'date', - required: true, - }, - { - key: 'company', - label: 'Firma', - type: 'string', - placeholder: 'Name des Lieferanten/Empfängers', - }, - { - key: 'desc', - label: 'Beschreibung', - type: 'textarea', - placeholder: 'Beschreibung der Position', - }, - { - key: 'tags', - label: 'Tags', - type: 'string', - placeholder: 'Komma-getrennte Tags', - helpText: 'z.B. Reise, Spesen, IT', - }, - { - key: 'bookingCurrency', - label: 'Buchungswährung', - type: 'enum', - required: true, - options: currencyOptions, - }, - { - key: 'bookingAmount', - label: 'Buchungsbetrag', - type: 'number', - required: true, - }, - { - key: 'originalCurrency', - label: 'Originalwährung', - type: 'enum', - required: true, - options: currencyOptions, - }, - { - key: 'originalAmount', - label: 'Originalbetrag', - type: 'number', - required: true, - helpText: 'Betrag in Originalwährung (keine automatische Umrechnung)', - }, - { - key: 'vatPercentage', - label: 'MwSt %', - type: 'number', - helpText: 'MwSt-Satz in Prozent (z.B. 8.1)', - }, - { - key: 'vatAmount', - label: 'MwSt Betrag', - type: 'number', - helpText: 'Wird automatisch berechnet (kann manuell überschrieben werden)', - }, - ], [contractOptions]); - - if (loading || optionsLoading) { - return
Lade Positionen...
; - } - - if (error) { - return
Fehler: {error}
; - } - - const onDelete = async (posId: string) => { - if (window.confirm('Position wirklich löschen?')) { - const success = await handleDelete(posId); - if (success) { - refetch(); - } + const [isCreateMode, setIsCreateMode] = useState(false); + + // Initial fetch + useEffect(() => { + if (instanceId) { + refetch(); + } + }, [instanceId]); + + // Generate columns from attributes + const columns = useMemo(() => { + return (attributes || []).map(attr => ({ + key: attr.name, + label: attr.label || attr.name, + type: attr.type as any, + sortable: attr.sortable !== false, + filterable: attr.filterable !== false, + searchable: attr.searchable !== false, + width: attr.width || 150, + minWidth: attr.minWidth || 100, + maxWidth: attr.maxWidth || 400, + })); + }, [attributes]); + + // Check permissions + const canCreate = permissions?.create !== 'n'; + const canUpdate = permissions?.update !== 'n'; + const canDelete = permissions?.delete !== 'n'; + + // Handle edit click + const handleEditClick = async (pos: TrusteePosition) => { + const fullPos = await fetchById(pos.id); + if (fullPos) { + setEditingPosition(fullPos); + setIsCreateMode(false); } }; - - const onEdit = async (pos: TrusteePosition) => { - setEditingPosition(pos); - setFormError(null); - // Lade Contracts für die Organisation - if (pos.organisationId) { - const contracts = await loadContractsForOrganisation(pos.organisationId); - setContractOptions(contracts); - } - setIsModalOpen(true); - }; - - const onCreate = () => { + + // Handle create click + const handleCreateClick = () => { setEditingPosition(null); - setFormError(null); - setContractOptions([]); - setIsModalOpen(true); + setIsCreateMode(true); }; - - const onCloseModal = () => { - setIsModalOpen(false); - setEditingPosition(null); - setFormError(null); - setContractOptions([]); - }; - - const onSave = async (data: Partial) => { - setFormError(null); - - // MwSt automatisch berechnen wenn nicht gesetzt + + // Handle form submit + const handleFormSubmit = async (data: Partial) => { + // Auto-calculate VAT if provided const processedData = { ...data }; if (processedData.bookingAmount && processedData.vatPercentage && !processedData.vatAmount) { processedData.vatAmount = processedData.bookingAmount * (processedData.vatPercentage / 100); } - try { - if (editingPosition) { - const result = await handleUpdate(editingPosition.id, processedData); - if (!result.success) { - setFormError(result.error || 'Fehler beim Aktualisieren'); - return; - } - } else { - const result = await handleCreate(processedData); - if (!result.success) { - setFormError(result.error || 'Fehler beim Erstellen'); - return; - } + if (isCreateMode) { + const result = await handleCreate(processedData); + if (result.success) { + setIsCreateMode(false); + refetch(); + } + } else if (editingPosition) { + const result = await handleUpdate(editingPosition.id, processedData); + if (result.success) { + setEditingPosition(null); + refetch(); } - - onCloseModal(); - refetch(); - } catch (err: any) { - setFormError(err.message || 'Ein Fehler ist aufgetreten'); } }; - - // Formatiere Betrag - const formatAmount = (amount: number, currency: string) => { - return new Intl.NumberFormat('de-CH', { - style: 'currency', - currency: currency || 'CHF' - }).format(amount); - }; - - // Formatiere Datum - const formatDate = (dateStr: string | null | undefined) => { - if (!dateStr) return '-'; - try { - return new Date(dateStr).toLocaleDateString('de-CH'); - } catch { - return dateStr; + + // Handle delete + const handleDeletePos = async (pos: TrusteePosition) => { + if (window.confirm(`Position "${pos.desc || pos.id}" wirklich löschen?`)) { + removeOptimistically(pos.id); + const success = await handleDelete(pos.id); + if (!success) { + refetch(); // Revert on error + } } }; - - return ( -
- {/* Toolbar */} -
- {canCreate && ( - - )} - -
- - {/* Tabelle */} - {positions.length === 0 ? ( -
-

Keine Positionen vorhanden.

- ) : ( - - - - - - - - - - - - - - {positions.map((pos) => ( - - - - - - - - - - ))} - -
ValutaFirmaBeschreibungVertragBetragMwStAktionen
{formatDate(pos.valuta)}{pos.company || '-'} - {pos.desc || '-'} - {getLabelFast('contracts', pos.contractId)} - {formatAmount(pos.bookingAmount, pos.bookingCurrency)} - - {pos.vatPercentage > 0 ? ( - - {pos.vatPercentage}% - - ) : ( - - - )} - - {canUpdate && ( - - )} - {canDelete && ( - - )} -
- )} - - {/* Create/Edit Modal */} - - {formError && ( -
- {formError} +
+ ); + } + + return ( +
+
+
+

Buchungspositionen verwalten

+
+
+ + {canCreate && ( + + )} +
+
+ +
+ {loading && (!positions || positions.length === 0) ? ( +
+
+ Lade Positionen...
+ ) : !positions || positions.length === 0 ? ( +
+ +

Keine Positionen vorhanden

+

+ Erstellen Sie eine neue Position, um zu beginnen. +

+ {canCreate && ( + + )} +
+ ) : ( + deletingItems.has(row.id), + }] : []), + ]} + onDelete={handleDeletePos} + hookData={{ + refetch, + permissions, + pagination, + handleDelete, + handleInlineUpdate, + updateOptimistically, + }} + emptyMessage="Keine Positionen gefunden" + /> )} - - initialData={editingPosition || { - bookingCurrency: 'CHF', - originalCurrency: 'CHF', - bookingAmount: 0, - originalAmount: 0, - vatPercentage: 0, - vatAmount: 0, - }} - fields={fields} - onSave={onSave} - onCancel={onCloseModal} - isSaving={creatingItem} - isEdit={!!editingPosition} - saveLabel={editingPosition ? 'Aktualisieren' : 'Erstellen'} - /> - +
+ + {/* Create/Edit Modal */} + {(editingPosition || isCreateMode) && ( +
+
e.stopPropagation()}> +
+

+ {isCreateMode ? 'Neue Position' : 'Position bearbeiten'} +

+ +
+
+ {formAttributes.length === 0 ? ( +
+
+ Lade Formular... +
+ ) : ( + + )} +
+
+
+ )}
); }; diff --git a/src/pages/views/trustee/TrusteeRolesView.tsx b/src/pages/views/trustee/TrusteeRolesView.tsx deleted file mode 100644 index 70febec..0000000 --- a/src/pages/views/trustee/TrusteeRolesView.tsx +++ /dev/null @@ -1,197 +0,0 @@ -/** - * TrusteeRolesView - * - * Rollen-Verwaltung für eine Trustee-Instanz. - * Rollen definieren Berechtigungen (admin, operate, userreport). - * Hinweis: Nur SysAdmin kann Rollen verwalten. - */ - -import React, { useState, useMemo } from 'react'; -import { useTrusteeRoles, useTrusteeRoleOperations, TrusteeRole } from '../../../hooks/useTrustee'; -import { useTablePermission } from '../../../hooks/useInstancePermissions'; -import { Popup } from '../../../components/UiComponents/Popup/Popup'; -import { TrusteeEditForm, FieldConfig } from './components'; -import styles from './TrusteeViews.module.css'; - -export const TrusteeRolesView: React.FC = () => { - const { items: roles, loading, error, refetch } = useTrusteeRoles(); - const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteeRoleOperations(); - const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeRole'); - - // Modal State - const [isModalOpen, setIsModalOpen] = useState(false); - const [editingRole, setEditingRole] = useState(null); - const [formError, setFormError] = useState(null); - - // Feld-Konfiguration für das Formular - const fields: FieldConfig[] = useMemo(() => [ - { - key: 'id', - label: 'Rollen-ID', - type: 'string', - required: true, - editable: !editingRole, // Nur bei Create editierbar - placeholder: 'z.B. admin, operate, userreport', - helpText: 'Eindeutige Rollen-ID (nicht änderbar nach Erstellung)', - }, - { - key: 'desc', - label: 'Beschreibung', - type: 'textarea', - required: true, - placeholder: 'Beschreibung der Rolle und ihrer Berechtigungen', - }, - ], [editingRole]); - - if (loading) { - return
Lade Rollen...
; - } - - if (error) { - return
Fehler: {error}
; - } - - const onDelete = async (roleId: string) => { - if (window.confirm('Rolle wirklich löschen? Dies ist nur möglich, wenn die Rolle nicht in Verwendung ist.')) { - const success = await handleDelete(roleId); - if (success) { - refetch(); - } - } - }; - - const onEdit = (role: TrusteeRole) => { - setEditingRole(role); - setFormError(null); - setIsModalOpen(true); - }; - - const onCreate = () => { - setEditingRole(null); - setFormError(null); - setIsModalOpen(true); - }; - - const onCloseModal = () => { - setIsModalOpen(false); - setEditingRole(null); - setFormError(null); - }; - - const onSave = async (data: Partial) => { - setFormError(null); - - try { - if (editingRole) { - const result = await handleUpdate(editingRole.id, data); - if (!result.success) { - setFormError(result.error || 'Fehler beim Aktualisieren'); - return; - } - } else { - const result = await handleCreate(data); - if (!result.success) { - setFormError(result.error || 'Fehler beim Erstellen'); - return; - } - } - - onCloseModal(); - refetch(); - } catch (err: any) { - setFormError(err.message || 'Ein Fehler ist aufgetreten'); - } - }; - - return ( -
- {/* Toolbar */} -
- {canCreate && ( - - )} - -
- - {/* Info */} -
- Rollen definieren Berechtigungen für Trustee-Zugriffe. Standard-Rollen: admin, operate, userreport. -
- - {/* Tabelle */} - {roles.length === 0 ? ( -
-

Keine Rollen vorhanden.

-
- ) : ( - - - - - - - - - - {roles.map((role) => ( - - - - - - ))} - -
IDBeschreibungAktionen
{role.id}{role.desc} - {canUpdate && ( - - )} - {canDelete && ( - - )} -
- )} - - {/* Create/Edit Modal */} - - {formError && ( -
- {formError} -
- )} - - initialData={editingRole || {}} - fields={fields} - onSave={onSave} - onCancel={onCloseModal} - isSaving={creatingItem} - isEdit={!!editingRole} - saveLabel={editingRole ? 'Aktualisieren' : 'Erstellen'} - /> -
-
- ); -}; - -export default TrusteeRolesView; diff --git a/src/pages/views/trustee/TrusteeViews.module.css b/src/pages/views/trustee/TrusteeViews.module.css index 394f6ea..eb3a037 100644 --- a/src/pages/views/trustee/TrusteeViews.module.css +++ b/src/pages/views/trustee/TrusteeViews.module.css @@ -200,6 +200,14 @@ color: var(--text-primary, #1a1a1a); } +/* Kompakte Variante für Text-Werte wie Rollen */ +.statValueSmall { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary, #1a1a1a); + line-height: 1.4; +} + .statLabel { font-size: 0.8125rem; color: var(--text-secondary, #666); @@ -298,6 +306,7 @@ } :global(.dark-theme) .statValue, +:global(.dark-theme) .statValueSmall, :global(.dark-theme) .infoSection h3, :global(.dark-theme) .infoValue { color: var(--text-primary-dark, #ffffff); @@ -484,3 +493,142 @@ :global(.dark-theme) .formActions { border-top-color: var(--border-dark, #333); } + +/* Instance Roles View */ +.rolesList { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.roleCard { + background: var(--bg-primary, #ffffff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + overflow: hidden; +} + +.roleHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + cursor: pointer; + transition: background-color 0.2s ease; +} + +.roleHeader:hover { + background: var(--surface-color, #f8f9fa); +} + +.roleInfo { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.expandIcon { + color: var(--text-secondary, #666); + font-size: 0.75rem; +} + +.roleLabel { + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +.roleDescription { + color: var(--text-secondary, #666); + font-size: 0.875rem; +} + +.roleBadges { + display: flex; + gap: 0.5rem; +} + +.systemBadge { + padding: 0.25rem 0.5rem; + background: var(--info-light, #e0f2fe); + color: var(--info-color, #0284c7); + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; +} + +.roleContent { + padding: 1rem 1.25rem; + border-top: 1px solid var(--border-color, #e0e0e0); + background: var(--surface-color, #f8f9fa); +} + +.infoBox { + display: flex; + align-items: center; + padding: 0.75rem 1rem; + background: var(--info-light, #e0f2fe); + border: 1px solid var(--info-color, #0284c7); + border-radius: 6px; + margin-bottom: 1rem; + color: var(--info-color, #0284c7); + font-size: 0.875rem; +} + +.emptyIcon { + font-size: 3rem; + color: var(--text-tertiary, #999); + margin-bottom: 1rem; +} + +.emptyHint { + font-size: 0.875rem; + color: var(--text-tertiary, #999); + margin-top: 0.5rem; +} + +.retryButton { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: var(--primary-color, #3b82f6); + color: white; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 0.875rem; + margin-top: 1rem; +} + +.retryButton:hover { + background: var(--primary-dark, #2563eb); +} + +/* Dark Theme - Instance Roles */ +:global(.dark-theme) .roleCard { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #333); +} + +:global(.dark-theme) .roleHeader:hover { + background: var(--surface-dark, #2a2a2a); +} + +:global(.dark-theme) .roleLabel { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .roleDescription { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .roleContent { + background: var(--surface-dark, #2a2a2a); + border-top-color: var(--border-dark, #333); +} + +:global(.dark-theme) .infoBox { + background: var(--info-dark, #0c4a6e); + border-color: var(--info-color, #0284c7); + color: var(--info-light, #e0f2fe); +} diff --git a/src/pages/views/trustee/components/TrusteeEditForm.tsx b/src/pages/views/trustee/components/TrusteeEditForm.tsx deleted file mode 100644 index b1b119f..0000000 --- a/src/pages/views/trustee/components/TrusteeEditForm.tsx +++ /dev/null @@ -1,329 +0,0 @@ -/** - * TrusteeEditForm - * - * Generisches Formular für Create/Edit von Trustee-Entities. - * Verwendet Feld-Definitionen aus Backend-Attributen oder manuelle Konfiguration. - */ - -import React, { useState, useEffect, useCallback } from 'react'; -import { useTrusteeOptions, TrusteeOption, TrusteeOptionEntity } from '../../../../hooks/useTrusteeOptions'; -import styles from '../TrusteeViews.module.css'; - -// ============================================================================ -// TYPES -// ============================================================================ - -export interface FieldConfig { - key: string; - label: string; - type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'number' | 'readonly'; - editable?: boolean; - required?: boolean; - options?: Array<{ value: string | number; label: string }>; - optionsReference?: string; // z.B. 'organisations', 'roles', 'contracts' - dependsOn?: string; // Feld-Key, von dem dieses Feld abhängt - placeholder?: string; - helpText?: string; -} - -export interface TrusteeEditFormProps> { - /** Aktuelle Daten (leer für Create) */ - initialData: Partial; - /** Feld-Konfigurationen */ - fields: FieldConfig[]; - /** Callback beim Speichern */ - onSave: (data: T) => Promise; - /** Callback beim Abbrechen */ - onCancel: () => void; - /** Speichern-Button Text */ - saveLabel?: string; - /** Abbrechen-Button Text */ - cancelLabel?: string; - /** Ist das Formular gerade am Speichern? */ - isSaving?: boolean; - /** Validierungs-Funktion */ - validate?: (data: Partial) => Record | null; - /** Ist es ein Edit (vs Create)? */ - isEdit?: boolean; -} - -// ============================================================================ -// COMPONENT -// ============================================================================ - -export function TrusteeEditForm>({ - initialData, - fields, - onSave, - onCancel, - saveLabel = 'Speichern', - cancelLabel = 'Abbrechen', - isSaving = false, - validate, - isEdit = false, -}: TrusteeEditFormProps) { - // Form State - const [formData, setFormData] = useState>(initialData); - const [errors, setErrors] = useState>({}); - const [touched, setTouched] = useState>(new Set()); - - // Options für Dropdowns - const { loadOptions, getOptions, loadContractsForOrganisation } = useTrusteeOptions(); - const [dynamicOptions, setDynamicOptions] = useState>({}); - const [loadingOptions, setLoadingOptions] = useState>(new Set()); - - // Reset form when initialData changes - useEffect(() => { - setFormData(initialData); - setErrors({}); - setTouched(new Set()); - }, [initialData]); - - // Lade Options für alle optionsReference-Felder - useEffect(() => { - const optionEntities = fields - .filter(f => f.optionsReference && ['organisations', 'roles', 'contracts', 'users', 'documents', 'positions'].includes(f.optionsReference)) - .map(f => f.optionsReference as TrusteeOptionEntity); - - const uniqueEntities = [...new Set(optionEntities)]; - if (uniqueEntities.length > 0) { - loadOptions(uniqueEntities); - } - }, [fields, loadOptions]); - - // Feld-Wert ändern - const handleChange = useCallback(async (fieldKey: string, value: any) => { - setFormData(prev => ({ ...prev, [fieldKey]: value })); - setTouched(prev => new Set(prev).add(fieldKey)); - - // Dynamische Abhängigkeiten behandeln - const dependentFields = fields.filter(f => f.dependsOn === fieldKey); - - for (const depField of dependentFields) { - // Reset dependent field value - setFormData(prev => ({ ...prev, [depField.key]: '' })); - - // Lade neue Options wenn es ein Contract-Dropdown ist, das von Organisation abhängt - if (depField.optionsReference === 'contracts' && fieldKey === 'organisationId' && value) { - setLoadingOptions(prev => new Set(prev).add(depField.key)); - try { - const contractOptions = await loadContractsForOrganisation(value); - setDynamicOptions(prev => ({ ...prev, [depField.key]: contractOptions })); - } finally { - setLoadingOptions(prev => { - const newSet = new Set(prev); - newSet.delete(depField.key); - return newSet; - }); - } - } - } - }, [fields, loadContractsForOrganisation]); - - // Validierung - const validateForm = useCallback((): boolean => { - const newErrors: Record = {}; - - // Required-Felder prüfen - fields.forEach(field => { - if (field.required && field.editable !== false) { - const value = formData[field.key]; - if (value === undefined || value === null || value === '') { - newErrors[field.key] = `${field.label} ist erforderlich`; - } - } - }); - - // Custom Validierung - if (validate) { - const customErrors = validate(formData); - if (customErrors) { - Object.assign(newErrors, customErrors); - } - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }, [fields, formData, validate]); - - // Speichern - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - // Alle Felder als touched markieren - setTouched(new Set(fields.map(f => f.key))); - - if (!validateForm()) { - return; - } - - try { - await onSave(formData as T); - } catch (err: any) { - setErrors({ _form: err.message || 'Fehler beim Speichern' }); - } - }; - - // Options für ein Feld holen - const getFieldOptions = (field: FieldConfig): TrusteeOption[] => { - // Statische Options - if (field.options) { - return field.options.map(o => ({ - value: String(o.value), - label: o.label - })); - } - - // Dynamische Options (z.B. nach Organisation gefilterte Contracts) - if (dynamicOptions[field.key]) { - return dynamicOptions[field.key]; - } - - // Options aus useTrusteeOptions - if (field.optionsReference) { - return getOptions(field.optionsReference as TrusteeOptionEntity); - } - - return []; - }; - - // Feld rendern - const renderField = (field: FieldConfig) => { - const value = formData[field.key] ?? ''; - const error = touched.has(field.key) ? errors[field.key] : undefined; - const isReadonly = field.editable === false || (isEdit && field.key === 'id'); - const isLoading = loadingOptions.has(field.key); - - // Prüfe ob abhängiges Feld disabled sein soll - const isDependentDisabled = field.dependsOn && !formData[field.dependsOn]; - - return ( -
- - - {field.type === 'boolean' ? ( - - ) : field.type === 'enum' ? ( - - ) : field.type === 'textarea' ? ( -