access rules editor enhanced

This commit is contained in:
ValueOn AG 2026-01-24 09:58:04 +01:00
parent 41e02b5a2c
commit 5952074626
24 changed files with 1544 additions and 2078 deletions

View file

@ -149,6 +149,8 @@ function App() {
<Route path="runs" element={<FeatureViewPage view="runs" />} /> <Route path="runs" element={<FeatureViewPage view="runs" />} />
<Route path="files" element={<FeatureViewPage view="files" />} /> <Route path="files" element={<FeatureViewPage view="files" />} />
<Route path="conversations" element={<FeatureViewPage view="conversations" />} /> <Route path="conversations" element={<FeatureViewPage view="conversations" />} />
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
{/* Catch-all für unbekannte Sub-Pfade */} {/* Catch-all für unbekannte Sub-Pfade */}
<Route path="*" element={<FeatureViewPage view="not-found" />} /> <Route path="*" element={<FeatureViewPage view="not-found" />} />

View file

@ -37,6 +37,8 @@ interface AccessRulesEditorProps {
isTemplate?: boolean; isTemplate?: boolean;
readOnly?: boolean; readOnly?: boolean;
onSave?: () => void; onSave?: () => void;
apiBasePath?: string;
mandateId?: string;
} }
type TabType = 'DATA' | 'UI' | 'RESOURCE' | 'JSON'; type TabType = 'DATA' | 'UI' | 'RESOURCE' | 'JSON';
@ -409,6 +411,8 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
isTemplate = false, isTemplate = false,
readOnly = false, readOnly = false,
onSave, onSave,
apiBasePath = '/api/rbac',
mandateId,
}) => { }) => {
const { const {
rules, rules,
@ -421,7 +425,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
updateRuleLocally, updateRuleLocally,
addRuleLocally, addRuleLocally,
removeRuleLocally, removeRuleLocally,
} = useAccessRules(roleId); } = useAccessRules(roleId, apiBasePath, mandateId);
const [activeTab, setActiveTab] = useState<TabType>('DATA'); const [activeTab, setActiveTab] = useState<TabType>('DATA');
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);

View file

@ -75,7 +75,7 @@ function instanceToTreeNode(
return { return {
id: instance.id, id: instance.id,
label: instance.instanceLabel, label: instance.instanceLabel,
badge: instance.userRole, // Note: badge für userRole entfernt - ein User kann mehrere Rollen haben
children, children,
defaultExpanded: false, defaultExpanded: false,
}; };

View file

@ -216,7 +216,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
<span className={styles.chevronSpacer} /> <span className={styles.chevronSpacer} />
)} )}
{node.icon && <span className={styles.nodeIcon}>{node.icon}</span>} {node.icon && <span className={styles.nodeIcon}>{node.icon}</span>}
<span className={styles.nodeLabel}>{node.label}</span> <span className={styles.nodeLabel} title={node.label}>{node.label}</span>
{node.badge !== undefined && ( {node.badge !== undefined && (
<span <span
className={`${styles.nodeBadge} ${node.badgeVariant ? styles[`badge${node.badgeVariant.charAt(0).toUpperCase() + node.badgeVariant.slice(1)}`] : ''}`} className={`${styles.nodeBadge} ${node.badgeVariant ? styles[`badge${node.badgeVariant.charAt(0).toUpperCase() + node.badgeVariant.slice(1)}`] : ''}`}

View file

@ -1,8 +1,8 @@
/** /**
* useAccessRules Hook * useAccessRules Hook
* *
* Hook for managing RBAC AccessRules for a specific role. * Hook for managing RBAC access rules for a role.
* Provides CRUD operations for access rules. * Supports both system admin (template roles) and feature admin (instance roles).
*/ */
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
@ -12,51 +12,14 @@ import api from '../api';
// TYPES // TYPES
// ============================================================================= // =============================================================================
export type AccessLevel = 'n' | 'm' | 'g' | 'a';
export type RuleContext = 'DATA' | 'UI' | 'RESOURCE'; export type RuleContext = 'DATA' | 'UI' | 'RESOURCE';
export type AccessLevel = 'n' | 'm' | 'g' | 'a' | null;
export interface AccessRule {
id: string;
roleId: string;
context: RuleContext;
item: string | null;
view: boolean;
read: AccessLevel | null;
create: AccessLevel | null;
update: AccessLevel | null;
delete: AccessLevel | null;
}
export interface AccessRuleCreate {
context: RuleContext;
item: string | null;
view?: boolean;
read?: AccessLevel | null;
create?: AccessLevel | null;
update?: AccessLevel | null;
delete?: AccessLevel | null;
}
export interface AccessRuleUpdate {
view?: boolean;
read?: AccessLevel | null;
create?: AccessLevel | null;
update?: AccessLevel | null;
delete?: AccessLevel | null;
}
// Grouped rules by context
export interface GroupedRules {
DATA: AccessRule[];
UI: AccessRule[];
RESOURCE: AccessRule[];
}
// ============================================================================= // =============================================================================
// ACCESS LEVEL LABELS // ACCESS LEVEL LABELS
// ============================================================================= // =============================================================================
export const ACCESS_LEVEL_OPTIONS: { value: AccessLevel; label: string; color: string }[] = [ export const ACCESS_LEVEL_OPTIONS: { value: 'n' | 'm' | 'g' | 'a'; label: string; color: string }[] = [
{ value: 'n', label: 'Keine', color: '#e53e3e' }, { value: 'n', label: 'Keine', color: '#e53e3e' },
{ value: 'm', label: 'Eigene', color: '#d69e2e' }, { value: 'm', label: 'Eigene', color: '#d69e2e' },
{ value: 'g', label: 'Gruppe', color: '#3182ce' }, { value: 'g', label: 'Gruppe', color: '#3182ce' },
@ -75,126 +38,159 @@ export const getAccessLevelColor = (level: AccessLevel | null): string => {
return option?.color || '#718096'; 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 // HOOK
// ============================================================================= // =============================================================================
export function useAccessRules(roleId: string | null) { export function useAccessRules(roleId: string, apiBasePath: string = '/api/rbac') {
const [rules, setRules] = useState<AccessRule[]>([]); const [rules, setRules] = useState<AccessRule[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// Determine if this is a feature-instance API path
const isInstanceApi = apiBasePath.includes('/instance-roles/');
/** /**
* Fetch all rules for the role * Fetch all rules for the role
*/ */
const fetchRules = useCallback(async (): Promise<AccessRule[]> => { const fetchRules = useCallback(async (): Promise<AccessRule[]> => {
if (!roleId) {
setRules([]);
return [];
}
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const response = await api.get(`/api/rbac/roles/${roleId}/rules`); // Different endpoint structure for instance roles vs system roles
const fetchedRules = Array.isArray(response.data) ? response.data : response.data.rules || []; 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); setRules(fetchedRules);
return fetchedRules; return fetchedRules;
} catch (err: any) { } catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch access rules'; const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Laden der Regeln';
setError(errorMessage); setError(errorMsg);
setRules([]); console.error('Error fetching rules:', err);
return []; return [];
} finally { } finally {
setLoading(false); 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 }> => { const saveRules = useCallback(async (rulesToSave: AccessRule[]): Promise<SaveResult> => {
if (!roleId) {
return { success: false, error: 'No role selected' };
}
setSaving(true); setSaving(true);
setError(null); setError(null);
try { try {
await api.put(`/api/rbac/roles/${roleId}/rules`, { rules: newRules }); // Different endpoint structure for instance roles vs system roles
setRules(newRules); 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 }; return { success: true };
} catch (err: any) { } catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to save access rules'; const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Speichern';
setError(errorMessage); setError(errorMsg);
return { success: false, error: errorMessage }; console.error('Error saving rules:', err);
return { success: false, error: errorMsg };
} finally { } finally {
setSaving(false); setSaving(false);
} }
}, [roleId]); }, [roleId, apiBasePath, isInstanceApi, fetchRules]);
/**
* 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);
}
}, []);
/** /**
* Get rules grouped by context * Get rules grouped by context
@ -208,21 +204,23 @@ export function useAccessRules(roleId: string | null) {
}, [rules]); }, [rules]);
/** /**
* Update rule locally (for optimistic updates) * Update a rule locally (not saved until saveRules is called)
*/ */
const updateRuleLocally = useCallback((ruleId: string, updates: Partial<AccessRule>) => { const updateRuleLocally = useCallback((ruleId: string, updates: Partial<AccessRule>) => {
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) => { const addRuleLocally = useCallback((rule: AccessRule) => {
setRules(prev => [...prev, rule]); 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) => { const removeRuleLocally = useCallback((ruleId: string) => {
setRules(prev => prev.filter(r => r.id !== ruleId)); setRules(prev => prev.filter(r => r.id !== ruleId));
@ -235,9 +233,6 @@ export function useAccessRules(roleId: string | null) {
error, error,
fetchRules, fetchRules,
saveRules, saveRules,
createRule,
updateRule,
deleteRule,
getGroupedRules, getGroupedRules,
updateRuleLocally, updateRuleLocally,
addRuleLocally, addRuleLocally,

View file

@ -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<AccessRule[]>([]);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(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<string, string> = {};
if (mandateId) {
headers['X-Mandate-Id'] = mandateId;
}
return headers;
}, [mandateId]);
/**
* Fetch all rules for the role
*/
const fetchRules = useCallback(async (): Promise<AccessRule[]> => {
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<SaveResult> => {
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<AccessRule>) => {
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;

View file

@ -96,7 +96,7 @@ export const FeatureLayout: React.FC = () => {
<span className={styles.instanceName}>{instance?.instanceLabel}</span> <span className={styles.instanceName}>{instance?.instanceLabel}</span>
</div> </div>
<div className={styles.roleIndicator}> <div className={styles.roleIndicator}>
<span className={styles.roleBadge}>{instance?.userRole}</span> <span className={styles.roleBadge}>{instance?.userRoles?.join(', ') || '-'}</span>
</div> </div>
</header> </header>

View file

@ -48,7 +48,7 @@ const InstanceCard: React.FC<InstanceCardProps> = ({ instance, featureLabel }) =
<div className={styles.cardContent}> <div className={styles.cardContent}>
<div className={styles.cardHeader}> <div className={styles.cardHeader}>
<span className={styles.featureLabel}>{featureLabel}</span> <span className={styles.featureLabel}>{featureLabel}</span>
<span className={styles.roleBadge}>{instance.userRole}</span> <span className={styles.roleBadge}>{instance.userRoles?.join(', ') || '-'}</span>
</div> </div>
<h3 className={styles.instanceLabel}>{instance.instanceLabel}</h3> <h3 className={styles.instanceLabel}>{instance.instanceLabel}</h3>
<p className={styles.mandateName}>{instance.mandateName}</p> <p className={styles.mandateName}>{instance.mandateName}</p>

View file

@ -11,13 +11,12 @@ import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
import { getLabel, FEATURE_REGISTRY } from '../types/mandate'; import { getLabel, FEATURE_REGISTRY } from '../types/mandate';
// Trustee Views // Trustee Views
import { TrusteeContractsView } from './views/trustee/TrusteeContractsView'; // Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
import { TrusteeOrganisationsView } from './views/trustee/TrusteeOrganisationsView';
import { TrusteeDocumentsView } from './views/trustee/TrusteeDocumentsView'; import { TrusteeDocumentsView } from './views/trustee/TrusteeDocumentsView';
import { TrusteePositionsView } from './views/trustee/TrusteePositionsView'; import { TrusteePositionsView } from './views/trustee/TrusteePositionsView';
import { TrusteeRolesView } from './views/trustee/TrusteeRolesView'; import { TrusteePositionDocumentsView } from './views/trustee/TrusteePositionDocumentsView';
import { TrusteeAccessView } from './views/trustee/TrusteeAccessView';
import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView'; import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView';
import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView';
import styles from './FeatureView.module.css'; import styles from './FeatureView.module.css';
@ -78,12 +77,10 @@ type ViewComponent = React.FC;
const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = { const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
trustee: { trustee: {
dashboard: TrusteeDashboardView, dashboard: TrusteeDashboardView,
organisations: TrusteeOrganisationsView,
contracts: TrusteeContractsView,
documents: TrusteeDocumentsView, documents: TrusteeDocumentsView,
positions: TrusteePositionsView, positions: TrusteePositionsView,
roles: TrusteeRolesView, 'position-documents': TrusteePositionDocumentsView,
access: TrusteeAccessView, 'instance-roles': TrusteeInstanceRolesView,
}, },
chatworkflow: { chatworkflow: {
dashboard: ChatworkflowDashboard, dashboard: ChatworkflowDashboard,

View file

@ -13,7 +13,8 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm'; import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaUserShield, 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 api from '../../api';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
@ -47,6 +48,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [editingRole, setEditingRole] = useState<FeatureRole | null>(null); const [editingRole, setEditingRole] = useState<FeatureRole | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [permissionsRole, setPermissionsRole] = useState<FeatureRole | null>(null);
// Load features on mount // Load features on mount
useEffect(() => { useEffect(() => {
@ -369,6 +371,14 @@ export const AdminFeatureRolesPage: React.FC = () => {
title: 'Rolle löschen', title: 'Rolle löschen',
} }
]} ]}
customActions={[
{
id: 'permissions',
icon: <FaShieldAlt />,
onClick: (role: FeatureRole) => setPermissionsRole(role),
title: 'Berechtigungen verwalten',
}
]}
onDelete={handleDeleteRole} onDelete={handleDeleteRole}
hookData={{ hookData={{
refetch: fetchRoles, refetch: fetchRoles,
@ -441,6 +451,39 @@ export const AdminFeatureRolesPage: React.FC = () => {
</div> </div>
</div> </div>
)} )}
{/* Permissions Modal */}
{permissionsRole && (
<div className={styles.modalOverlay} onClick={() => setPermissionsRole(null)}>
<div className={styles.modal} style={{ maxWidth: '900px', width: '90%' }} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>
<FaShieldAlt style={{ marginRight: 8 }} />
Berechtigungen: {permissionsRole.roleLabel}
</h2>
<button
className={styles.modalClose}
onClick={() => setPermissionsRole(null)}
>
</button>
</div>
<div className={styles.modalContent}>
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<FaCube style={{ marginRight: 8 }} />
<span>Feature: <strong>{permissionsRole.featureCode}</strong></span>
<span style={{ marginLeft: '1rem' }}>Template-Rolle (global)</span>
</div>
<AccessRulesEditor
roleId={permissionsRole.id}
roleName={permissionsRole.roleLabel}
isTemplate={true}
onSave={() => setPermissionsRole(null)}
/>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View file

@ -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<TrusteeAccess | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [contractOptions, setContractOptions] = useState<Array<{value: string, label: string}>>([]);
// 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 <div className={styles.loading}>Lade Zugriffe...</div>;
}
if (error) {
return <div className={styles.error}>Fehler: {error}</div>;
}
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<TrusteeAccess>) => {
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 (
<div className={styles.listView}>
{/* Toolbar */}
<div className={styles.toolbar}>
{canCreate && (
<button className={styles.primaryButton} onClick={onCreate}>
+ Neuer Zugriff
</button>
)}
<button className={styles.secondaryButton} onClick={() => refetch()}>
Aktualisieren
</button>
</div>
{/* Tabelle */}
{accessList.length === 0 ? (
<div className={styles.emptyState}>
<p>Keine Zugriffe definiert.</p>
</div>
) : (
<table className={styles.dataTable}>
<thead>
<tr>
<th>Benutzer</th>
<th>Organisation</th>
<th>Rolle</th>
<th>Vertrag</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{accessList.map((access) => (
<tr key={access.id}>
<td>{getLabelFast('users', access.userId)}</td>
<td>{getLabelFast('organisations', access.organisationId)}</td>
<td>
<span className={styles.badge}>
{getLabelFast('roles', access.roleId)}
</span>
</td>
<td>
{access.contractId ? (
getLabelFast('contracts', access.contractId)
) : (
<span className={styles.muted}>Alle</span>
)}
</td>
<td className={styles.actions}>
{canUpdate && (
<button
className={styles.iconButton}
title="Bearbeiten"
onClick={() => onEdit(access)}
>
</button>
)}
{canDelete && (
<button
className={styles.iconButton}
title="Entfernen"
onClick={() => onDelete(access.id)}
disabled={deletingItems.has(access.id)}
>
{deletingItems.has(access.id) ? '...' : '🗑️'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Create/Edit Modal */}
<Popup
isOpen={isModalOpen}
title={editingAccess ? 'Zugriff bearbeiten' : 'Neuer Zugriff'}
onClose={onCloseModal}
size="medium"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div>
)}
<TrusteeEditForm<TrusteeAccess>
initialData={editingAccess || {}}
fields={fields}
onSave={onSave}
onCancel={onCloseModal}
isSaving={creatingItem}
isEdit={!!editingAccess}
saveLabel={editingAccess ? 'Aktualisieren' : 'Erstellen'}
/>
</Popup>
</div>
);
};
export default TrusteeAccessView;

View file

@ -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<TrusteeContract | null>(null);
const [formError, setFormError] = useState<string | null>(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 <div className={styles.loading}>Lade Verträge...</div>;
}
if (error) {
return <div className={styles.error}>Fehler: {error}</div>;
}
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<TrusteeContract>) => {
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 (
<div className={styles.listView}>
{/* Toolbar */}
<div className={styles.toolbar}>
{canCreate && (
<button className={styles.primaryButton} onClick={onCreate}>
+ Neuer Vertrag
</button>
)}
<button className={styles.secondaryButton} onClick={() => refetch()}>
Aktualisieren
</button>
</div>
{/* Tabelle */}
{contracts.length === 0 ? (
<div className={styles.emptyState}>
<p>Keine Verträge vorhanden.</p>
</div>
) : (
<table className={styles.dataTable}>
<thead>
<tr>
<th>Bezeichnung</th>
<th>Organisation</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{contracts.map((contract) => (
<tr key={contract.id}>
<td>{contract.label}</td>
<td>{getLabelFast('organisations', contract.organisationId)}</td>
<td>
<span className={`${styles.badge} ${contract.enabled ? styles.badgeSuccess : styles.badgeWarning}`}>
{contract.enabled ? 'Aktiv' : 'Inaktiv'}
</span>
</td>
<td className={styles.actions}>
{canUpdate && (
<button
className={styles.iconButton}
title="Bearbeiten"
onClick={() => onEdit(contract)}
>
</button>
)}
{canDelete && (
<button
className={styles.iconButton}
title="Löschen"
onClick={() => onDelete(contract.id)}
disabled={deletingItems.has(contract.id)}
>
{deletingItems.has(contract.id) ? '...' : '🗑️'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Create/Edit Modal */}
<Popup
isOpen={isModalOpen}
title={editingContract ? 'Vertrag bearbeiten' : 'Neuer Vertrag'}
onClose={onCloseModal}
size="medium"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div>
)}
<TrusteeEditForm<TrusteeContract>
initialData={editingContract || { enabled: true }}
fields={fields}
onSave={onSave}
onCancel={onCloseModal}
isSaving={creatingItem}
isEdit={!!editingContract}
saveLabel={editingContract ? 'Aktualisieren' : 'Erstellen'}
/>
</Popup>
</div>
);
};
export default TrusteeContractsView;

View file

@ -1,53 +1,73 @@
/** /**
* TrusteeDashboardView * 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 React from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useTrusteeOrganisations } from '../../../hooks/useTrustee'; import { useTrusteePositions, useTrusteeDocuments, useTrusteePositionDocuments } from '../../../hooks/useTrustee';
import { useTrusteeContracts } from '../../../hooks/useTrustee';
import styles from './TrusteeViews.module.css'; import styles from './TrusteeViews.module.css';
export const TrusteeDashboardView: React.FC = () => { export const TrusteeDashboardView: React.FC = () => {
const { instance } = useCurrentInstance(); const { instance } = useCurrentInstance();
const { items: organisations, loading: orgsLoading } = useTrusteeOrganisations(); const { items: positions, loading: posLoading } = useTrusteePositions();
const { items: contracts, loading: contractsLoading } = useTrusteeContracts(); const { items: documents, loading: docsLoading } = useTrusteeDocuments();
const { items: links, loading: linksLoading } = useTrusteePositionDocuments();
const isLoading = orgsLoading || contractsLoading; const isLoading = posLoading || docsLoading || linksLoading;
return ( return (
<div className={styles.dashboardView}> <div className={styles.dashboardView}>
<div className={styles.statsGrid}> <div className={styles.statsGrid}>
{/* Organisationen Card */} {/* Positionen Card */}
<div className={styles.statCard}> <div className={styles.statCard}>
<div className={styles.statIcon}>🏢</div> <div className={styles.statIcon}>📊</div>
<div className={styles.statContent}> <div className={styles.statContent}>
<div className={styles.statValue}> <div className={styles.statValue}>
{isLoading ? '...' : organisations.length} {isLoading ? '...' : positions.length}
</div> </div>
<div className={styles.statLabel}>Organisationen</div> <div className={styles.statLabel}>Positionen</div>
</div> </div>
</div> </div>
{/* Verträge Card */} {/* Dokumente Card */}
<div className={styles.statCard}> <div className={styles.statCard}>
<div className={styles.statIcon}>📄</div> <div className={styles.statIcon}>📄</div>
<div className={styles.statContent}> <div className={styles.statContent}>
<div className={styles.statValue}> <div className={styles.statValue}>
{isLoading ? '...' : contracts.length} {isLoading ? '...' : documents.length}
</div> </div>
<div className={styles.statLabel}>Verträge</div> <div className={styles.statLabel}>Dokumente</div>
</div> </div>
</div> </div>
{/* Rolle Card */} {/* Verknüpfungen Card */}
<div className={styles.statCard}>
<div className={styles.statIcon}>🔗</div>
<div className={styles.statContent}>
<div className={styles.statValue}>
{isLoading ? '...' : links.length}
</div>
<div className={styles.statLabel}>Zuordnungen</div>
</div>
</div>
{/* Rollen Card */}
<div className={styles.statCard}> <div className={styles.statCard}>
<div className={styles.statIcon}>👤</div> <div className={styles.statIcon}>👤</div>
<div className={styles.statContent}> <div className={styles.statContent}>
<div className={styles.statValue}>{instance?.userRole || '-'}</div> <div className={styles.statValueSmall}>
<div className={styles.statLabel}>Deine Rolle</div> {instance?.userRoles?.length ? (
instance.userRoles.map((role, idx) => (
<div key={idx}>{role}</div>
))
) : '-'}
</div>
<div className={styles.statLabel}>
{(instance?.userRoles?.length || 0) === 1 ? 'Deine Rolle' : 'Deine Rollen'}
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -2,154 +2,129 @@
* TrusteeDocumentsView * TrusteeDocumentsView
* *
* Dokument-Verwaltung für eine Trustee-Instanz. * 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 { useTrusteeDocuments, useTrusteeDocumentOperations, TrusteeDocument } from '../../../hooks/useTrustee';
import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions';
import { useTablePermission } from '../../../hooks/useInstancePermissions';
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { Popup } from '../../../components/UiComponents/Popup/Popup'; import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
import { TrusteeEditForm, FieldConfig } from './components'; import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaFileAlt, FaDownload } from 'react-icons/fa';
import api from '../../../api'; import api from '../../../api';
import styles from './TrusteeViews.module.css'; import styles from '../../admin/Admin.module.css';
export const TrusteeDocumentsView: React.FC = () => { export const TrusteeDocumentsView: React.FC = () => {
const instanceId = useInstanceId(); 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 // Entity hook
const { getLabelFast, loading: optionsLoading, loadContractsForOrganisation } = useTrusteeOptions(['organisations', 'contracts']); const {
items: documents,
// Modal State attributes,
const [isModalOpen, setIsModalOpen] = useState(false); permissions,
const [editingDoc, setEditingDoc] = useState<TrusteeDocument | null>(null); pagination,
const [formError, setFormError] = useState<string | null>(null); loading,
const [downloading, setDownloading] = useState<string | null>(null); error,
const [contractOptions, setContractOptions] = useState<Array<{value: string, label: string}>>([]); refetch,
fetchById,
// MIME-Type Options updateOptimistically,
const mimeTypeOptions = [ removeOptimistically,
{ value: 'application/pdf', label: 'PDF' }, } = useTrusteeDocuments();
{ value: 'image/jpeg', label: 'JPEG' },
{ value: 'image/png', label: 'PNG' }, // Operations hook
{ value: 'application/octet-stream', label: 'Andere' }, const {
]; handleDelete,
handleCreate,
// Feld-Konfiguration für das Formular handleUpdate,
const fields: FieldConfig[] = useMemo(() => [ deletingItems,
{ } = useTrusteeDocumentOperations();
key: 'organisationId',
label: 'Organisation', // Modal state
type: 'enum', const [editingDocument, setEditingDocument] = useState<TrusteeDocument | null>(null);
required: true, const [isCreateMode, setIsCreateMode] = useState(false);
optionsReference: 'organisations', const [downloadingId, setDownloadingId] = useState<string | null>(null);
},
{ // Initial fetch
key: 'contractId', useEffect(() => {
label: 'Vertrag', if (instanceId) {
type: 'enum', refetch();
required: true, }
options: contractOptions, }, [instanceId]);
dependsOn: 'organisationId',
}, // Generate columns from attributes
{ const columns = useMemo(() => {
key: 'documentName', return (attributes || []).map(attr => ({
label: 'Dokumentname', key: attr.name,
type: 'string', label: attr.label || attr.name,
required: true, type: attr.type as any,
placeholder: 'z.B. Rechnung_2026.pdf', sortable: attr.sortable !== false,
}, filterable: attr.filterable !== false,
{ searchable: attr.searchable !== false,
key: 'documentMimeType', width: attr.width || 150,
label: 'Dateityp', minWidth: attr.minWidth || 100,
type: 'enum', maxWidth: attr.maxWidth || 400,
required: true, }));
options: mimeTypeOptions, }, [attributes]);
},
], [contractOptions]); // Check permissions
const canCreate = permissions?.create !== 'n';
if (loading || optionsLoading) { const canUpdate = permissions?.update !== 'n';
return <div className={styles.loading}>Lade Dokumente...</div>; const canDelete = permissions?.delete !== 'n';
}
// Handle edit click
if (error) { const handleEditClick = async (doc: TrusteeDocument) => {
return <div className={styles.error}>Fehler: {error}</div>; const fullDoc = await fetchById(doc.id);
} if (fullDoc) {
setEditingDocument(fullDoc);
const onDelete = async (docId: string) => { setIsCreateMode(false);
if (window.confirm('Dokument wirklich löschen?')) { }
const success = await handleDelete(docId); };
if (success) {
// Handle create click
const handleCreateClick = () => {
setEditingDocument(null);
setIsCreateMode(true);
};
// Handle form submit
const handleFormSubmit = async (data: Partial<TrusteeDocument>) => {
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(); refetch();
} }
} }
}; };
const onEdit = async (doc: TrusteeDocument) => { // Handle delete
setEditingDoc(doc); const handleDeleteDoc = async (doc: TrusteeDocument) => {
setFormError(null); if (window.confirm(`Dokument "${doc.documentName}" wirklich löschen?`)) {
// Lade Contracts für die Organisation removeOptimistically(doc.id);
if (doc.organisationId) { const success = await handleDelete(doc.id);
const contracts = await loadContractsForOrganisation(doc.organisationId); if (!success) {
setContractOptions(contracts); refetch(); // Revert on error
}
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<TrusteeDocument>) => {
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;
}
} }
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; if (!instanceId) return;
setDownloading(doc.id); setDownloadingId(doc.id);
try { try {
const response = await api.get( const response = await api.get(
`/api/trustee/${instanceId}/documents/${doc.id}/data`, `/api/trustee/${instanceId}/documents/${doc.id}/data`,
{ responseType: 'blob' } { responseType: 'blob' }
); );
// Blob-Download
const blob = new Blob([response.data], { type: doc.documentMimeType }); const blob = new Blob([response.data], { type: doc.documentMimeType });
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const link = document.createElement('a'); const link = document.createElement('a');
@ -163,112 +138,174 @@ export const TrusteeDocumentsView: React.FC = () => {
console.error('Download error:', err); console.error('Download error:', err);
alert('Fehler beim Herunterladen des Dokuments.'); alert('Fehler beim Herunterladen des Dokuments.');
} finally { } finally {
setDownloading(null); setDownloadingId(null);
} }
}; };
// MIME-Type zu lesbarem Text // Close modal
const getMimeTypeLabel = (mimeType: string) => { const handleCloseModal = () => {
const found = mimeTypeOptions.find(o => o.value === mimeType); setEditingDocument(null);
return found?.label || mimeType?.split('/')[1]?.toUpperCase() || 'Unbekannt'; setIsCreateMode(false);
}; };
return ( // Form attributes (exclude system fields)
<div className={styles.listView}> const formAttributes = useMemo(() => {
{/* Toolbar */} const excludedFields = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
<div className={styles.toolbar}> return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
{canCreate && ( }, [attributes]);
<button className={styles.primaryButton} onClick={onCreate}>
+ Neues Dokument // Handle inline update
const handleInlineUpdate = async (itemId: string, updateData: Partial<TrusteeDocument>, row: TrusteeDocument) => {
updateOptimistically(itemId, updateData);
const result = await handleUpdate(itemId, { ...row, ...updateData });
if (!result.success) {
refetch(); // Revert on error
}
};
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Dokumente: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button> </button>
)}
<button className={styles.secondaryButton} onClick={() => refetch()}>
Aktualisieren
</button>
</div>
{/* Tabelle */}
{documents.length === 0 ? (
<div className={styles.emptyState}>
<p>Keine Dokumente vorhanden.</p>
</div> </div>
) : ( </div>
<table className={styles.dataTable}> );
<thead> }
<tr>
<th>Name</th> return (
<th>Typ</th> <div className={styles.adminPage}>
<th>Vertrag</th> <div className={styles.pageHeader}>
<th>Aktionen</th> <div>
</tr> <p className={styles.pageSubtitle}>Belege und Dokumente verwalten</p>
</thead> </div>
<tbody> <div className={styles.headerActions}>
{documents.map((doc) => ( <button
<tr key={doc.id}> className={styles.secondaryButton}
<td>{doc.documentName}</td> onClick={() => refetch()}
<td> disabled={loading}
<span className={styles.badge}> >
{getMimeTypeLabel(doc.documentMimeType)} <FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</span> </button>
</td> {canCreate && (
<td>{getLabelFast('contracts', doc.contractId)}</td> <button
<td className={styles.actions}> className={styles.primaryButton}
<button onClick={handleCreateClick}
className={styles.iconButton} >
title="Herunterladen" + Neues Dokument
onClick={() => onDownload(doc)} </button>
disabled={downloading === doc.id} )}
> </div>
{downloading === doc.id ? '...' : '⬇️'} </div>
</button>
{canUpdate && ( <div className={styles.tableContainer}>
<button {loading && (!documents || documents.length === 0) ? (
className={styles.iconButton} <div className={styles.loadingContainer}>
title="Bearbeiten" <div className={styles.spinner} />
onClick={() => onEdit(doc)} <span>Lade Dokumente...</span>
>
</button>
)}
{canDelete && (
<button
className={styles.iconButton}
title="Löschen"
onClick={() => onDelete(doc.id)}
disabled={deletingItems.has(doc.id)}
>
{deletingItems.has(doc.id) ? '...' : '🗑️'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Create/Edit Modal */}
<Popup
isOpen={isModalOpen}
title={editingDoc ? 'Dokument bearbeiten' : 'Neues Dokument'}
onClose={onCloseModal}
size="medium"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div> </div>
) : !documents || documents.length === 0 ? (
<div className={styles.emptyState}>
<FaFileAlt className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Dokumente vorhanden</h3>
<p className={styles.emptyDescription}>
Erstellen Sie ein neues Dokument, um zu beginnen.
</p>
{canCreate && (
<button
className={styles.primaryButton}
onClick={handleCreateClick}
>
+ Neues Dokument
</button>
)}
</div>
) : (
<FormGeneratorTable
data={documents}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: TrusteeDocument) => deletingItems.has(row.id),
}] : []),
]}
customActions={[
{
id: 'download',
icon: <FaDownload />,
onClick: handleDownload,
title: 'Herunterladen',
loading: (row: TrusteeDocument) => downloadingId === row.id,
},
]}
onDelete={handleDeleteDoc}
hookData={{
refetch,
permissions,
pagination,
handleDelete,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Dokumente gefunden"
/>
)} )}
<TrusteeEditForm<TrusteeDocument> </div>
initialData={editingDoc || { documentMimeType: 'application/pdf' }}
fields={fields} {/* Create/Edit Modal */}
onSave={onSave} {(editingDocument || isCreateMode) && (
onCancel={onCloseModal} <div className={styles.modalOverlay} onClick={handleCloseModal}>
isSaving={creatingItem} <div className={styles.modal} onClick={e => e.stopPropagation()}>
isEdit={!!editingDoc} <div className={styles.modalHeader}>
saveLabel={editingDoc ? 'Aktualisieren' : 'Erstellen'} <h2 className={styles.modalTitle}>
/> {isCreateMode ? 'Neues Dokument' : 'Dokument bearbeiten'}
</Popup> </h2>
<button
className={styles.modalClose}
onClick={handleCloseModal}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingDocument || {}}
mode={isCreateMode ? 'create' : 'edit'}
onSubmit={handleFormSubmit}
onCancel={handleCloseModal}
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View file

@ -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<InstanceRole[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(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 (
<div className={styles.viewContainer}>
<div className={styles.error}>Keine Feature-Instanz ausgewählt</div>
</div>
);
}
if (loading) {
return (
<div className={styles.viewContainer}>
<div className={styles.loading}>Lade Instanz-Rollen...</div>
</div>
);
}
if (error) {
return (
<div className={styles.viewContainer}>
<div className={styles.error}>
<p>{error}</p>
<button onClick={fetchRoles} className={styles.retryButton}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.viewContainer}>
<div className={styles.viewHeader}>
<div className={styles.headerLeft}>
<h2 className={styles.viewTitle}>
<FaUserShield style={{ marginRight: '0.5rem' }} />
Instanz-Rollen & Berechtigungen
</h2>
<p className={styles.viewSubtitle}>
Verwalten Sie die Berechtigungen für die Rollen dieser Trustee-Instanz
</p>
</div>
<div className={styles.headerActions}>
<button onClick={fetchRoles} className={styles.secondaryButton}>
<FaSync /> Aktualisieren
</button>
</div>
</div>
<div className={styles.infoBox}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
<span>
Diese Rollen wurden von den Feature-Templates kopiert.
Änderungen hier gelten nur für diese Instanz.
</span>
</div>
{roles.length === 0 ? (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<p>Keine Instanz-Rollen gefunden</p>
<p className={styles.emptyHint}>
Instanz-Rollen werden automatisch erstellt, wenn Benutzer dieser Instanz zugewiesen werden.
</p>
</div>
) : (
<div className={styles.rolesList}>
{roles.map(role => (
<div key={role.id} className={styles.roleCard}>
<div
className={styles.roleHeader}
onClick={() => toggleRole(role.id)}
>
<div className={styles.roleInfo}>
<span className={styles.expandIcon}>
{expandedRoleId === role.id ? <FaChevronDown /> : <FaChevronRight />}
</span>
<span className={styles.roleLabel}>{role.roleLabel}</span>
<span className={styles.roleDescription}>
{getTextValue(role.description)}
</span>
</div>
<div className={styles.roleBadges}>
{role.isSystemRole && (
<span className={styles.systemBadge}>System</span>
)}
</div>
</div>
{expandedRoleId === role.id && (
<div className={styles.roleContent}>
<AccessRulesEditor
roleId={role.id}
roleName={role.roleLabel}
isTemplate={false}
apiBasePath={`/api/trustee/${instance.id}/instance-roles/${role.id}`}
mandateId={instance.mandateId}
/>
</div>
)}
</div>
))}
</div>
)}
</div>
);
};
export default TrusteeInstanceRolesView;

View file

@ -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<TrusteeOrganisation | null>(null);
const [formError, setFormError] = useState<string | null>(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 <div className={styles.loading}>Lade Organisationen...</div>;
}
if (error) {
return <div className={styles.error}>Fehler: {error}</div>;
}
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<TrusteeOrganisation>) => {
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<TrusteeOrganisation>): Record<string, string> | null => {
const errors: Record<string, string> = {};
// 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 (
<div className={styles.listView}>
{/* Toolbar */}
<div className={styles.toolbar}>
{canCreate && (
<button className={styles.primaryButton} onClick={onCreate}>
+ Neue Organisation
</button>
)}
<button className={styles.secondaryButton} onClick={() => refetch()}>
Aktualisieren
</button>
</div>
{/* Tabelle */}
{organisations.length === 0 ? (
<div className={styles.emptyState}>
<p>Keine Organisationen vorhanden.</p>
</div>
) : (
<table className={styles.dataTable}>
<thead>
<tr>
<th>ID</th>
<th>Bezeichnung</th>
<th>Status</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{organisations.map((org) => (
<tr key={org.id}>
<td className={styles.monospace}>{org.id}</td>
<td>{org.label}</td>
<td>
<span className={`${styles.badge} ${org.enabled ? styles.badgeSuccess : styles.badgeWarning}`}>
{org.enabled ? 'Aktiv' : 'Inaktiv'}
</span>
</td>
<td className={styles.actions}>
{canUpdate && (
<button
className={styles.iconButton}
title="Bearbeiten"
onClick={() => onEdit(org)}
>
</button>
)}
{canDelete && (
<button
className={styles.iconButton}
title="Löschen"
onClick={() => onDelete(org.id)}
disabled={deletingItems.has(org.id)}
>
{deletingItems.has(org.id) ? '...' : '🗑️'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Create/Edit Modal */}
<Popup
isOpen={isModalOpen}
title={editingOrg ? 'Organisation bearbeiten' : 'Neue Organisation'}
onClose={onCloseModal}
size="medium"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div>
)}
<TrusteeEditForm<TrusteeOrganisation>
initialData={editingOrg || { enabled: true }}
fields={fields}
onSave={onSave}
onCancel={onCloseModal}
isSaving={creatingItem}
validate={validateOrganisation}
isEdit={!!editingOrg}
saveLabel={editingOrg ? 'Aktualisieren' : 'Erstellen'}
/>
</Popup>
</div>
);
};
export default TrusteeOrganisationsView;

View file

@ -2,203 +2,230 @@
* TrusteePositionDocumentsView * TrusteePositionDocumentsView
* *
* Verknüpfungs-Verwaltung zwischen Positionen und Dokumenten. * 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 { useTrusteePositionDocuments, useTrusteePositionDocumentOperations, TrusteePositionDocument } from '../../../hooks/useTrustee';
import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useTablePermission } from '../../../hooks/useInstancePermissions'; import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
import { Popup } from '../../../components/UiComponents/Popup/Popup'; import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
import { TrusteeEditForm, FieldConfig } from './components'; import { FaSync, FaLink } from 'react-icons/fa';
import styles from './TrusteeViews.module.css'; import styles from '../../admin/Admin.module.css';
export const TrusteePositionDocumentsView: React.FC = () => { export const TrusteePositionDocumentsView: React.FC = () => {
const { items: links, loading, error, refetch } = useTrusteePositionDocuments(); const instanceId = useInstanceId();
const { handleDelete, handleCreate, deletingItems, creatingItem } = useTrusteePositionDocumentOperations();
const { canCreate, canDelete } = useTablePermission('TrusteePositionDocument');
// Options für Label-Auflösung // Entity hook
const { getLabelFast, loading: optionsLoading, loadContractsForOrganisation } = useTrusteeOptions(['organisations', 'contracts', 'positions', 'documents']); const {
items: links,
// Modal State attributes,
const [isModalOpen, setIsModalOpen] = useState(false); permissions,
const [formError, setFormError] = useState<string | null>(null); pagination,
const [contractOptions, setContractOptions] = useState<Array<{value: string, label: string}>>([]); loading,
error,
// Feld-Konfiguration für das Formular refetch,
const fields: FieldConfig[] = useMemo(() => [ removeOptimistically,
{ } = useTrusteePositionDocuments();
key: 'organisationId',
label: 'Organisation', // Operations hook
type: 'enum', const {
required: true, handleDelete,
optionsReference: 'organisations', handleCreate,
}, deletingItems,
{ creatingItem,
key: 'contractId', } = useTrusteePositionDocumentOperations();
label: 'Vertrag',
type: 'enum', // Modal state
required: true, const [isCreateMode, setIsCreateMode] = useState(false);
options: contractOptions,
dependsOn: 'organisationId', // Initial fetch
}, useEffect(() => {
{ if (instanceId) {
key: 'positionId', refetch();
label: 'Position', }
type: 'enum', }, [instanceId]);
required: true,
optionsReference: 'positions', // Generate columns from attributes
helpText: 'Die Buchungsposition, der ein Beleg zugewiesen werden soll', const columns = useMemo(() => {
}, return (attributes || []).map(attr => ({
{ key: attr.name,
key: 'documentId', label: attr.label || attr.name,
label: 'Dokument', type: attr.type as any,
type: 'enum', sortable: attr.sortable !== false,
required: true, filterable: attr.filterable !== false,
optionsReference: 'documents', searchable: attr.searchable !== false,
helpText: 'Der Beleg, der der Position zugewiesen werden soll', width: attr.width || 150,
}, minWidth: attr.minWidth || 100,
], [contractOptions]); maxWidth: attr.maxWidth || 400,
}));
if (loading || optionsLoading) { }, [attributes]);
return <div className={styles.loading}>Lade Verknüpfungen...</div>;
} // Check permissions
const canCreate = permissions?.create !== 'n';
if (error) { const canDelete = permissions?.delete !== 'n';
return <div className={styles.error}>Fehler: {error}</div>;
} // Handle create click
const handleCreateClick = () => {
const onDelete = async (linkId: string) => { setIsCreateMode(true);
if (window.confirm('Verknüpfung wirklich entfernen?')) { };
const success = await handleDelete(linkId);
if (success) { // Handle form submit
refetch(); const handleFormSubmit = async (data: Partial<TrusteePositionDocument>) => {
} const result = await handleCreate(data);
} if (result.success) {
}; setIsCreateMode(false);
const onCreate = () => {
setFormError(null);
setContractOptions([]);
setIsModalOpen(true);
};
const onCloseModal = () => {
setIsModalOpen(false);
setFormError(null);
setContractOptions([]);
};
const onSave = async (data: Partial<TrusteePositionDocument>) => {
setFormError(null);
try {
const result = await handleCreate(data);
if (!result.success) {
setFormError(result.error || 'Fehler beim Erstellen');
return;
}
onCloseModal();
refetch(); refetch();
} catch (err: any) {
setFormError(err.message || 'Ein Fehler ist aufgetreten');
} }
}; };
// Gruppiere nach Position für bessere Übersicht // Handle delete
const groupedByPosition = useMemo(() => { const handleDeleteLink = async (link: TrusteePositionDocument) => {
const grouped: Record<string, TrusteePositionDocument[]> = {}; if (window.confirm('Verknüpfung wirklich entfernen?')) {
links.forEach(link => { removeOptimistically(link.id);
if (!grouped[link.positionId]) { const success = await handleDelete(link.id);
grouped[link.positionId] = []; if (!success) {
refetch(); // Revert on error
} }
grouped[link.positionId].push(link); }
}); };
return grouped;
}, [links]); // Close modal
const handleCloseModal = () => {
return ( setIsCreateMode(false);
<div className={styles.listView}> };
{/* Toolbar */}
<div className={styles.toolbar}> // Form attributes (exclude system fields)
{canCreate && ( const formAttributes = useMemo(() => {
<button className={styles.primaryButton} onClick={onCreate}> const excludedFields = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
+ Neue Verknüpfung return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Verknüpfungen: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button> </button>
)}
<button className={styles.secondaryButton} onClick={() => refetch()}>
Aktualisieren
</button>
</div>
{/* Info */}
<div className={styles.muted} style={{ fontSize: '0.8125rem', padding: '0.5rem 0' }}>
Hier verknüpfen Sie Belege (Dokumente) mit Buchungspositionen.
</div>
{/* Tabelle */}
{links.length === 0 ? (
<div className={styles.emptyState}>
<p>Keine Verknüpfungen vorhanden.</p>
</div> </div>
) : ( </div>
<table className={styles.dataTable}> );
<thead> }
<tr>
<th>Position</th> return (
<th>Dokument</th> <div className={styles.adminPage}>
<th>Vertrag</th> <div className={styles.pageHeader}>
<th>Aktionen</th> <div>
</tr> <p className={styles.pageSubtitle}>Belege mit Buchungspositionen verknüpfen</p>
</thead> </div>
<tbody> <div className={styles.headerActions}>
{links.map((link) => ( <button
<tr key={link.id}> className={styles.secondaryButton}
<td>{getLabelFast('positions', link.positionId)}</td> onClick={() => refetch()}
<td>{getLabelFast('documents', link.documentId)}</td> disabled={loading}
<td>{getLabelFast('contracts', link.contractId)}</td> >
<td className={styles.actions}> <FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
{canDelete && ( </button>
<button {canCreate && (
className={styles.iconButton} <button
title="Verknüpfung entfernen" className={styles.primaryButton}
onClick={() => onDelete(link.id)} onClick={handleCreateClick}
disabled={deletingItems.has(link.id)} >
> + Neue Verknüpfung
{deletingItems.has(link.id) ? '...' : '🗑️'} </button>
</button> )}
)} </div>
</td> </div>
</tr>
))} <div className={styles.tableContainer}>
</tbody> {loading && (!links || links.length === 0) ? (
</table> <div className={styles.loadingContainer}>
)} <div className={styles.spinner} />
<span>Lade Verknüpfungen...</span>
{/* Create Modal */}
<Popup
isOpen={isModalOpen}
title="Neue Verknüpfung erstellen"
onClose={onCloseModal}
size="medium"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div> </div>
) : !links || links.length === 0 ? (
<div className={styles.emptyState}>
<FaLink className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Verknüpfungen vorhanden</h3>
<p className={styles.emptyDescription}>
Verknüpfen Sie Belege mit Buchungspositionen.
</p>
{canCreate && (
<button
className={styles.primaryButton}
onClick={handleCreateClick}
>
+ Neue Verknüpfung
</button>
)}
</div>
) : (
<FormGeneratorTable
data={links}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canDelete ? [{
type: 'delete' as const,
title: 'Verknüpfung entfernen',
loading: (row: TrusteePositionDocument) => deletingItems.has(row.id),
}] : []),
]}
onDelete={handleDeleteLink}
hookData={{
refetch,
permissions,
pagination,
handleDelete,
}}
emptyMessage="Keine Verknüpfungen gefunden"
/>
)} )}
<TrusteeEditForm<TrusteePositionDocument> </div>
initialData={{}}
fields={fields} {/* Create Modal */}
onSave={onSave} {isCreateMode && (
onCancel={onCloseModal} <div className={styles.modalOverlay} onClick={handleCloseModal}>
isSaving={creatingItem} <div className={styles.modal} onClick={e => e.stopPropagation()}>
isEdit={false} <div className={styles.modalHeader}>
saveLabel="Verknüpfung erstellen" <h2 className={styles.modalTitle}>Neue Verknüpfung erstellen</h2>
/> <button
</Popup> className={styles.modalClose}
onClick={handleCloseModal}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={{}}
mode="create"
onSubmit={handleFormSubmit}
onCancel={handleCloseModal}
submitButtonText="Verknüpfung erstellen"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View file

@ -2,322 +2,285 @@
* TrusteePositionsView * TrusteePositionsView
* *
* Positions-Verwaltung für eine Trustee-Instanz. * 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 { useTrusteePositions, useTrusteePositionOperations, TrusteePosition } from '../../../hooks/useTrustee';
import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { useTablePermission } from '../../../hooks/useInstancePermissions'; import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
import { Popup } from '../../../components/UiComponents/Popup/Popup'; import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
import { TrusteeEditForm, FieldConfig } from './components'; import { FaSync, FaReceipt } from 'react-icons/fa';
import styles from './TrusteeViews.module.css'; import styles from '../../admin/Admin.module.css';
export const TrusteePositionsView: React.FC = () => { export const TrusteePositionsView: React.FC = () => {
const { items: positions, loading, error, refetch } = useTrusteePositions(); const instanceId = useInstanceId();
const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteePositionOperations();
const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteePosition');
// Options für Label-Auflösung // Entity hook
const { getLabelFast, loading: optionsLoading, loadContractsForOrganisation } = useTrusteeOptions(['organisations', 'contracts']); const {
items: positions,
// Modal State attributes,
const [isModalOpen, setIsModalOpen] = useState(false); permissions,
pagination,
loading,
error,
refetch,
fetchById,
updateOptimistically,
removeOptimistically,
} = useTrusteePositions();
// Operations hook
const {
handleDelete,
handleCreate,
handleUpdate,
deletingItems,
creatingItem,
} = useTrusteePositionOperations();
// Modal state
const [editingPosition, setEditingPosition] = useState<TrusteePosition | null>(null); const [editingPosition, setEditingPosition] = useState<TrusteePosition | null>(null);
const [formError, setFormError] = useState<string | null>(null); const [isCreateMode, setIsCreateMode] = useState(false);
const [contractOptions, setContractOptions] = useState<Array<{value: string, label: string}>>([]);
// Initial fetch
// Währungs-Options useEffect(() => {
const currencyOptions = [ if (instanceId) {
{ value: 'CHF', label: 'CHF' }, refetch();
{ value: 'EUR', label: 'EUR' }, }
{ value: 'USD', label: 'USD' }, }, [instanceId]);
{ value: 'GBP', label: 'GBP' },
]; // Generate columns from attributes
const columns = useMemo(() => {
// Feld-Konfiguration für das Formular return (attributes || []).map(attr => ({
const fields: FieldConfig[] = useMemo(() => [ key: attr.name,
{ label: attr.label || attr.name,
key: 'organisationId', type: attr.type as any,
label: 'Organisation', sortable: attr.sortable !== false,
type: 'enum', filterable: attr.filterable !== false,
required: true, searchable: attr.searchable !== false,
optionsReference: 'organisations', width: attr.width || 150,
}, minWidth: attr.minWidth || 100,
{ maxWidth: attr.maxWidth || 400,
key: 'contractId', }));
label: 'Vertrag', }, [attributes]);
type: 'enum',
required: true, // Check permissions
options: contractOptions, const canCreate = permissions?.create !== 'n';
dependsOn: 'organisationId', const canUpdate = permissions?.update !== 'n';
}, const canDelete = permissions?.delete !== 'n';
{
key: 'valuta', // Handle edit click
label: 'Valutadatum', const handleEditClick = async (pos: TrusteePosition) => {
type: 'date', const fullPos = await fetchById(pos.id);
required: true, if (fullPos) {
}, setEditingPosition(fullPos);
{ setIsCreateMode(false);
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 <div className={styles.loading}>Lade Positionen...</div>;
}
if (error) {
return <div className={styles.error}>Fehler: {error}</div>;
}
const onDelete = async (posId: string) => {
if (window.confirm('Position wirklich löschen?')) {
const success = await handleDelete(posId);
if (success) {
refetch();
}
} }
}; };
const onEdit = async (pos: TrusteePosition) => { // Handle create click
setEditingPosition(pos); const handleCreateClick = () => {
setFormError(null);
// Lade Contracts für die Organisation
if (pos.organisationId) {
const contracts = await loadContractsForOrganisation(pos.organisationId);
setContractOptions(contracts);
}
setIsModalOpen(true);
};
const onCreate = () => {
setEditingPosition(null); setEditingPosition(null);
setFormError(null); setIsCreateMode(true);
setContractOptions([]);
setIsModalOpen(true);
}; };
const onCloseModal = () => { // Handle form submit
setIsModalOpen(false); const handleFormSubmit = async (data: Partial<TrusteePosition>) => {
setEditingPosition(null); // Auto-calculate VAT if provided
setFormError(null);
setContractOptions([]);
};
const onSave = async (data: Partial<TrusteePosition>) => {
setFormError(null);
// MwSt automatisch berechnen wenn nicht gesetzt
const processedData = { ...data }; const processedData = { ...data };
if (processedData.bookingAmount && processedData.vatPercentage && !processedData.vatAmount) { if (processedData.bookingAmount && processedData.vatPercentage && !processedData.vatAmount) {
processedData.vatAmount = processedData.bookingAmount * (processedData.vatPercentage / 100); processedData.vatAmount = processedData.bookingAmount * (processedData.vatPercentage / 100);
} }
try { if (isCreateMode) {
if (editingPosition) { const result = await handleCreate(processedData);
const result = await handleUpdate(editingPosition.id, processedData); if (result.success) {
if (!result.success) { setIsCreateMode(false);
setFormError(result.error || 'Fehler beim Aktualisieren'); refetch();
return; }
} } else if (editingPosition) {
} else { const result = await handleUpdate(editingPosition.id, processedData);
const result = await handleCreate(processedData); if (result.success) {
if (!result.success) { setEditingPosition(null);
setFormError(result.error || 'Fehler beim Erstellen'); refetch();
return;
}
} }
onCloseModal();
refetch();
} catch (err: any) {
setFormError(err.message || 'Ein Fehler ist aufgetreten');
} }
}; };
// Formatiere Betrag // Handle delete
const formatAmount = (amount: number, currency: string) => { const handleDeletePos = async (pos: TrusteePosition) => {
return new Intl.NumberFormat('de-CH', { if (window.confirm(`Position "${pos.desc || pos.id}" wirklich löschen?`)) {
style: 'currency', removeOptimistically(pos.id);
currency: currency || 'CHF' const success = await handleDelete(pos.id);
}).format(amount); if (!success) {
}; refetch(); // Revert on error
}
// Formatiere Datum
const formatDate = (dateStr: string | null | undefined) => {
if (!dateStr) return '-';
try {
return new Date(dateStr).toLocaleDateString('de-CH');
} catch {
return dateStr;
} }
}; };
return ( // Close modal
<div className={styles.listView}> const handleCloseModal = () => {
{/* Toolbar */} setEditingPosition(null);
<div className={styles.toolbar}> setIsCreateMode(false);
{canCreate && ( };
<button className={styles.primaryButton} onClick={onCreate}>
+ Neue Position // Form attributes (exclude system fields)
const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy'];
return (attributes || []).filter(attr => !excludedFields.includes(attr.name));
}, [attributes]);
// Handle inline update
const handleInlineUpdate = async (itemId: string, updateData: Partial<TrusteePosition>, row: TrusteePosition) => {
updateOptimistically(itemId, updateData);
const result = await handleUpdate(itemId, { ...row, ...updateData });
if (!result.success) {
refetch(); // Revert on error
}
};
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden der Positionen: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button> </button>
)}
<button className={styles.secondaryButton} onClick={() => refetch()}>
Aktualisieren
</button>
</div>
{/* Tabelle */}
{positions.length === 0 ? (
<div className={styles.emptyState}>
<p>Keine Positionen vorhanden.</p>
</div> </div>
) : ( </div>
<table className={styles.dataTable}> );
<thead> }
<tr>
<th>Valuta</th> return (
<th>Firma</th> <div className={styles.adminPage}>
<th>Beschreibung</th> <div className={styles.pageHeader}>
<th>Vertrag</th> <div>
<th className={styles.alignRight}>Betrag</th> <p className={styles.pageSubtitle}>Buchungspositionen verwalten</p>
<th className={styles.alignRight}>MwSt</th> </div>
<th>Aktionen</th> <div className={styles.headerActions}>
</tr> <button
</thead> className={styles.secondaryButton}
<tbody> onClick={() => refetch()}
{positions.map((pos) => ( disabled={loading}
<tr key={pos.id}> >
<td>{formatDate(pos.valuta)}</td> <FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
<td>{pos.company || '-'}</td> </button>
<td className={styles.truncate} title={pos.desc}> {canCreate && (
{pos.desc || '-'} <button
</td> className={styles.primaryButton}
<td>{getLabelFast('contracts', pos.contractId)}</td> onClick={handleCreateClick}
<td className={styles.alignRight}> >
{formatAmount(pos.bookingAmount, pos.bookingCurrency)} + Neue Position
</td> </button>
<td className={styles.alignRight}> )}
{pos.vatPercentage > 0 ? ( </div>
<span title={formatAmount(pos.vatAmount, pos.bookingCurrency)}> </div>
{pos.vatPercentage}%
</span> <div className={styles.tableContainer}>
) : ( {loading && (!positions || positions.length === 0) ? (
<span className={styles.muted}>-</span> <div className={styles.loadingContainer}>
)} <div className={styles.spinner} />
</td> <span>Lade Positionen...</span>
<td className={styles.actions}>
{canUpdate && (
<button
className={styles.iconButton}
title="Bearbeiten"
onClick={() => onEdit(pos)}
>
</button>
)}
{canDelete && (
<button
className={styles.iconButton}
title="Löschen"
onClick={() => onDelete(pos.id)}
disabled={deletingItems.has(pos.id)}
>
{deletingItems.has(pos.id) ? '...' : '🗑️'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Create/Edit Modal */}
<Popup
isOpen={isModalOpen}
title={editingPosition ? 'Position bearbeiten' : 'Neue Position'}
onClose={onCloseModal}
size="large"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div> </div>
) : !positions || positions.length === 0 ? (
<div className={styles.emptyState}>
<FaReceipt className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Positionen vorhanden</h3>
<p className={styles.emptyDescription}>
Erstellen Sie eine neue Position, um zu beginnen.
</p>
{canCreate && (
<button
className={styles.primaryButton}
onClick={handleCreateClick}
>
+ Neue Position
</button>
)}
</div>
) : (
<FormGeneratorTable
data={positions}
columns={columns}
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: TrusteePosition) => deletingItems.has(row.id),
}] : []),
]}
onDelete={handleDeletePos}
hookData={{
refetch,
permissions,
pagination,
handleDelete,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="Keine Positionen gefunden"
/>
)} )}
<TrusteeEditForm<TrusteePosition> </div>
initialData={editingPosition || {
bookingCurrency: 'CHF', {/* Create/Edit Modal */}
originalCurrency: 'CHF', {(editingPosition || isCreateMode) && (
bookingAmount: 0, <div className={styles.modalOverlay} onClick={handleCloseModal}>
originalAmount: 0, <div className={styles.modal} onClick={e => e.stopPropagation()}>
vatPercentage: 0, <div className={styles.modalHeader}>
vatAmount: 0, <h2 className={styles.modalTitle}>
}} {isCreateMode ? 'Neue Position' : 'Position bearbeiten'}
fields={fields} </h2>
onSave={onSave} <button
onCancel={onCloseModal} className={styles.modalClose}
isSaving={creatingItem} onClick={handleCloseModal}
isEdit={!!editingPosition} >
saveLabel={editingPosition ? 'Aktualisieren' : 'Erstellen'}
/> </button>
</Popup> </div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingPosition || {
bookingCurrency: 'CHF',
originalCurrency: 'CHF',
bookingAmount: 0,
originalAmount: 0,
vatPercentage: 0,
vatAmount: 0,
}}
mode={isCreateMode ? 'create' : 'edit'}
onSubmit={handleFormSubmit}
onCancel={handleCloseModal}
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div> </div>
); );
}; };

View file

@ -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<TrusteeRole | null>(null);
const [formError, setFormError] = useState<string | null>(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 <div className={styles.loading}>Lade Rollen...</div>;
}
if (error) {
return <div className={styles.error}>Fehler: {error}</div>;
}
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<TrusteeRole>) => {
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 (
<div className={styles.listView}>
{/* Toolbar */}
<div className={styles.toolbar}>
{canCreate && (
<button className={styles.primaryButton} onClick={onCreate}>
+ Neue Rolle
</button>
)}
<button className={styles.secondaryButton} onClick={() => refetch()}>
Aktualisieren
</button>
</div>
{/* Info */}
<div className={styles.muted} style={{ fontSize: '0.8125rem', padding: '0.5rem 0' }}>
Rollen definieren Berechtigungen für Trustee-Zugriffe. Standard-Rollen: admin, operate, userreport.
</div>
{/* Tabelle */}
{roles.length === 0 ? (
<div className={styles.emptyState}>
<p>Keine Rollen vorhanden.</p>
</div>
) : (
<table className={styles.dataTable}>
<thead>
<tr>
<th>ID</th>
<th>Beschreibung</th>
<th>Aktionen</th>
</tr>
</thead>
<tbody>
{roles.map((role) => (
<tr key={role.id}>
<td className={styles.monospace}>{role.id}</td>
<td>{role.desc}</td>
<td className={styles.actions}>
{canUpdate && (
<button
className={styles.iconButton}
title="Bearbeiten"
onClick={() => onEdit(role)}
>
</button>
)}
{canDelete && (
<button
className={styles.iconButton}
title="Löschen"
onClick={() => onDelete(role.id)}
disabled={deletingItems.has(role.id)}
>
{deletingItems.has(role.id) ? '...' : '🗑️'}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
{/* Create/Edit Modal */}
<Popup
isOpen={isModalOpen}
title={editingRole ? 'Rolle bearbeiten' : 'Neue Rolle'}
onClose={onCloseModal}
size="medium"
>
{formError && (
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
{formError}
</div>
)}
<TrusteeEditForm<TrusteeRole>
initialData={editingRole || {}}
fields={fields}
onSave={onSave}
onCancel={onCloseModal}
isSaving={creatingItem}
isEdit={!!editingRole}
saveLabel={editingRole ? 'Aktualisieren' : 'Erstellen'}
/>
</Popup>
</div>
);
};
export default TrusteeRolesView;

View file

@ -200,6 +200,14 @@
color: var(--text-primary, #1a1a1a); 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 { .statLabel {
font-size: 0.8125rem; font-size: 0.8125rem;
color: var(--text-secondary, #666); color: var(--text-secondary, #666);
@ -298,6 +306,7 @@
} }
:global(.dark-theme) .statValue, :global(.dark-theme) .statValue,
:global(.dark-theme) .statValueSmall,
:global(.dark-theme) .infoSection h3, :global(.dark-theme) .infoSection h3,
:global(.dark-theme) .infoValue { :global(.dark-theme) .infoValue {
color: var(--text-primary-dark, #ffffff); color: var(--text-primary-dark, #ffffff);
@ -484,3 +493,142 @@
:global(.dark-theme) .formActions { :global(.dark-theme) .formActions {
border-top-color: var(--border-dark, #333); 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);
}

View file

@ -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<T = Record<string, any>> {
/** Aktuelle Daten (leer für Create) */
initialData: Partial<T>;
/** Feld-Konfigurationen */
fields: FieldConfig[];
/** Callback beim Speichern */
onSave: (data: T) => Promise<void>;
/** 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<T>) => Record<string, string> | null;
/** Ist es ein Edit (vs Create)? */
isEdit?: boolean;
}
// ============================================================================
// COMPONENT
// ============================================================================
export function TrusteeEditForm<T extends Record<string, any>>({
initialData,
fields,
onSave,
onCancel,
saveLabel = 'Speichern',
cancelLabel = 'Abbrechen',
isSaving = false,
validate,
isEdit = false,
}: TrusteeEditFormProps<T>) {
// Form State
const [formData, setFormData] = useState<Partial<T>>(initialData);
const [errors, setErrors] = useState<Record<string, string>>({});
const [touched, setTouched] = useState<Set<string>>(new Set());
// Options für Dropdowns
const { loadOptions, getOptions, loadContractsForOrganisation } = useTrusteeOptions();
const [dynamicOptions, setDynamicOptions] = useState<Record<string, TrusteeOption[]>>({});
const [loadingOptions, setLoadingOptions] = useState<Set<string>>(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<string, string> = {};
// 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 (
<div key={field.key} className={styles.formField}>
<label htmlFor={field.key}>
{field.label}
{field.required && <span style={{ color: 'var(--error-color, #dc2626)' }}> *</span>}
</label>
{field.type === 'boolean' ? (
<label style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<input
type="checkbox"
id={field.key}
checked={Boolean(value)}
onChange={(e) => handleChange(field.key, e.target.checked)}
disabled={isReadonly || isSaving}
/>
{field.helpText || 'Aktiviert'}
</label>
) : field.type === 'enum' ? (
<select
id={field.key}
value={String(value)}
onChange={(e) => handleChange(field.key, e.target.value)}
disabled={isReadonly || isSaving || isLoading || isDependentDisabled}
>
<option value="">
{isLoading ? 'Lade...' : isDependentDisabled ? `Bitte ${fields.find(f => f.key === field.dependsOn)?.label} wählen` : '-- Auswählen --'}
</option>
{getFieldOptions(field).map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
) : field.type === 'textarea' ? (
<textarea
id={field.key}
value={String(value)}
onChange={(e) => handleChange(field.key, e.target.value)}
disabled={isReadonly || isSaving}
placeholder={field.placeholder}
rows={3}
/>
) : field.type === 'number' ? (
<input
type="number"
id={field.key}
value={value === '' ? '' : Number(value)}
onChange={(e) => handleChange(field.key, e.target.value === '' ? '' : Number(e.target.value))}
disabled={isReadonly || isSaving}
placeholder={field.placeholder}
step="any"
/>
) : field.type === 'date' ? (
<input
type="date"
id={field.key}
value={String(value)}
onChange={(e) => handleChange(field.key, e.target.value)}
disabled={isReadonly || isSaving}
/>
) : field.type === 'readonly' ? (
<input
type="text"
id={field.key}
value={String(value)}
disabled
style={{ background: 'var(--surface-color, #f5f5f5)' }}
/>
) : (
<input
type={field.type === 'email' ? 'email' : 'text'}
id={field.key}
value={String(value)}
onChange={(e) => handleChange(field.key, e.target.value)}
disabled={isReadonly || isSaving}
placeholder={field.placeholder}
/>
)}
{error && (
<span style={{ color: 'var(--error-color, #dc2626)', fontSize: '0.75rem' }}>
{error}
</span>
)}
{field.helpText && !error && (
<span style={{ color: 'var(--text-tertiary, #888)', fontSize: '0.75rem' }}>
{field.helpText}
</span>
)}
</div>
);
};
return (
<form className={styles.form} onSubmit={handleSubmit}>
{/* Form-Level Error */}
{errors._form && (
<div className={styles.formError}>
{errors._form}
</div>
)}
{/* Fields */}
{fields.filter(f => f.type !== 'readonly' || isEdit).map(renderField)}
{/* Actions */}
<div className={styles.formActions}>
<button
type="button"
className={styles.secondaryButton}
onClick={onCancel}
disabled={isSaving}
>
{cancelLabel}
</button>
<button
type="submit"
className={styles.primaryButton}
disabled={isSaving}
>
{isSaving ? 'Speichern...' : saveLabel}
</button>
</div>
</form>
);
}
export default TrusteeEditForm;

View file

@ -1,6 +1,7 @@
/** /**
* Trustee Components * Trustee Components Export
*
* Note: TrusteeEditForm wurde entfernt - verwende FormGeneratorForm stattdessen
*/ */
export { TrusteeEditForm } from './TrusteeEditForm'; // Keine lokalen Komponenten mehr - alle Views nutzen FormGeneratorTable/FormGeneratorForm
export type { FieldConfig, TrusteeEditFormProps } from './TrusteeEditForm';

View file

@ -1,12 +1,12 @@
/** /**
* Trustee Views Export * Trustee Views Export
*
* Note: TrusteeOrganisationsView, TrusteeContractsView, TrusteeRolesView, TrusteeAccessView
* wurden entfernt - Feature-Instanz = Organisation
*/ */
export { TrusteeDashboardView } from './TrusteeDashboardView'; export { TrusteeDashboardView } from './TrusteeDashboardView';
export { TrusteeContractsView } from './TrusteeContractsView';
export { TrusteeOrganisationsView } from './TrusteeOrganisationsView';
export { TrusteeDocumentsView } from './TrusteeDocumentsView'; export { TrusteeDocumentsView } from './TrusteeDocumentsView';
export { TrusteePositionsView } from './TrusteePositionsView'; export { TrusteePositionsView } from './TrusteePositionsView';
export { TrusteePositionDocumentsView } from './TrusteePositionDocumentsView'; export { TrusteePositionDocumentsView } from './TrusteePositionDocumentsView';
export { TrusteeRolesView } from './TrusteeRolesView'; export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
export { TrusteeAccessView } from './TrusteeAccessView';

View file

@ -82,7 +82,7 @@ export interface FeatureInstance {
mandateId: string; // Zugehöriger Mandant mandateId: string; // Zugehöriger Mandant
mandateName: string; // Für Anzeige mandateName: string; // Für Anzeige
instanceLabel: string; // z.B. "PamoCreate AG" instanceLabel: string; // z.B. "PamoCreate AG"
userRole: string; // Rolle des Users in dieser Instanz userRoles: string[]; // Rollen des Users in dieser Instanz (kann mehrere haben)
permissions: InstancePermissions; permissions: InstancePermissions;
} }
@ -186,14 +186,13 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
code: 'trustee', code: 'trustee',
label: { de: 'Treuhand', en: 'Trustee' }, label: { de: 'Treuhand', en: 'Trustee' },
icon: 'briefcase', icon: 'briefcase',
// Note: Feature-Instanz = Organisation (kein separates Organisations-Objekt)
views: [ views: [
{ code: 'dashboard', label: { de: 'Übersicht', en: 'Dashboard' }, path: 'dashboard' }, { code: 'dashboard', label: { de: 'Übersicht', en: 'Dashboard' }, path: 'dashboard' },
{ code: 'organisations', label: { de: 'Organisationen', en: 'Organisations' }, path: 'organisations' },
{ code: 'contracts', label: { de: 'Verträge', en: 'Contracts' }, path: 'contracts' },
{ code: 'documents', label: { de: 'Dokumente', en: 'Documents' }, path: 'documents' },
{ code: 'positions', label: { de: 'Positionen', en: 'Positions' }, path: 'positions' }, { code: 'positions', label: { de: 'Positionen', en: 'Positions' }, path: 'positions' },
{ code: 'roles', label: { de: 'Rollen', en: 'Roles' }, path: 'roles' }, { code: 'documents', label: { de: 'Dokumente', en: 'Documents' }, path: 'documents' },
{ code: 'access', label: { de: 'Zugriffe', en: 'Access' }, path: 'access' }, { code: 'position-documents', label: { de: 'Zuordnungen', en: 'Assignments' }, path: 'position-documents' },
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true },
] ]
}, },
chatworkflow: { chatworkflow: {