access rules editor enhanced
This commit is contained in:
parent
41e02b5a2c
commit
5952074626
24 changed files with 1544 additions and 2078 deletions
|
|
@ -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" />} />
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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)}`] : ''}`}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
254
src/hooks/useAccessRules.tsx
Normal file
254
src/hooks/useAccessRules.tsx
Normal 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;
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
fetchById,
|
||||||
|
updateOptimistically,
|
||||||
|
removeOptimistically,
|
||||||
|
} = useTrusteeDocuments();
|
||||||
|
|
||||||
// Modal State
|
// Operations hook
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const {
|
||||||
const [editingDoc, setEditingDoc] = useState<TrusteeDocument | null>(null);
|
handleDelete,
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
handleCreate,
|
||||||
const [downloading, setDownloading] = useState<string | null>(null);
|
handleUpdate,
|
||||||
const [contractOptions, setContractOptions] = useState<Array<{value: string, label: string}>>([]);
|
deletingItems,
|
||||||
|
} = useTrusteeDocumentOperations();
|
||||||
|
|
||||||
// MIME-Type Options
|
// Modal state
|
||||||
const mimeTypeOptions = [
|
const [editingDocument, setEditingDocument] = useState<TrusteeDocument | null>(null);
|
||||||
{ value: 'application/pdf', label: 'PDF' },
|
const [isCreateMode, setIsCreateMode] = useState(false);
|
||||||
{ value: 'image/jpeg', label: 'JPEG' },
|
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||||
{ value: 'image/png', label: 'PNG' },
|
|
||||||
{ value: 'application/octet-stream', label: 'Andere' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Feld-Konfiguration für das Formular
|
// Initial fetch
|
||||||
const fields: FieldConfig[] = useMemo(() => [
|
useEffect(() => {
|
||||||
{
|
if (instanceId) {
|
||||||
key: 'organisationId',
|
|
||||||
label: 'Organisation',
|
|
||||||
type: 'enum',
|
|
||||||
required: true,
|
|
||||||
optionsReference: 'organisations',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'contractId',
|
|
||||||
label: 'Vertrag',
|
|
||||||
type: 'enum',
|
|
||||||
required: true,
|
|
||||||
options: contractOptions,
|
|
||||||
dependsOn: 'organisationId',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'documentName',
|
|
||||||
label: 'Dokumentname',
|
|
||||||
type: 'string',
|
|
||||||
required: true,
|
|
||||||
placeholder: 'z.B. Rechnung_2026.pdf',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'documentMimeType',
|
|
||||||
label: 'Dateityp',
|
|
||||||
type: 'enum',
|
|
||||||
required: true,
|
|
||||||
options: mimeTypeOptions,
|
|
||||||
},
|
|
||||||
], [contractOptions]);
|
|
||||||
|
|
||||||
if (loading || optionsLoading) {
|
|
||||||
return <div className={styles.loading}>Lade Dokumente...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return <div className={styles.error}>Fehler: {error}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDelete = async (docId: string) => {
|
|
||||||
if (window.confirm('Dokument wirklich löschen?')) {
|
|
||||||
const success = await handleDelete(docId);
|
|
||||||
if (success) {
|
|
||||||
refetch();
|
refetch();
|
||||||
}
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
// Generate columns from attributes
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return (attributes || []).map(attr => ({
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: attr.type as any,
|
||||||
|
sortable: attr.sortable !== false,
|
||||||
|
filterable: attr.filterable !== false,
|
||||||
|
searchable: attr.searchable !== false,
|
||||||
|
width: attr.width || 150,
|
||||||
|
minWidth: attr.minWidth || 100,
|
||||||
|
maxWidth: attr.maxWidth || 400,
|
||||||
|
}));
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
const canCreate = permissions?.create !== 'n';
|
||||||
|
const canUpdate = permissions?.update !== 'n';
|
||||||
|
const canDelete = permissions?.delete !== 'n';
|
||||||
|
|
||||||
|
// Handle edit click
|
||||||
|
const handleEditClick = async (doc: TrusteeDocument) => {
|
||||||
|
const fullDoc = await fetchById(doc.id);
|
||||||
|
if (fullDoc) {
|
||||||
|
setEditingDocument(fullDoc);
|
||||||
|
setIsCreateMode(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEdit = async (doc: TrusteeDocument) => {
|
// Handle create click
|
||||||
setEditingDoc(doc);
|
const handleCreateClick = () => {
|
||||||
setFormError(null);
|
setEditingDocument(null);
|
||||||
// Lade Contracts für die Organisation
|
setIsCreateMode(true);
|
||||||
if (doc.organisationId) {
|
|
||||||
const contracts = await loadContractsForOrganisation(doc.organisationId);
|
|
||||||
setContractOptions(contracts);
|
|
||||||
}
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCreate = () => {
|
// Handle form submit
|
||||||
setEditingDoc(null);
|
const handleFormSubmit = async (data: Partial<TrusteeDocument>) => {
|
||||||
setFormError(null);
|
if (isCreateMode) {
|
||||||
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);
|
const result = await handleCreate(data);
|
||||||
if (!result.success) {
|
if (result.success) {
|
||||||
setFormError(result.error || 'Fehler beim Erstellen');
|
setIsCreateMode(false);
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onCloseModal();
|
|
||||||
refetch();
|
refetch();
|
||||||
} catch (err: any) {
|
}
|
||||||
setFormError(err.message || 'Ein Fehler ist aufgetreten');
|
} else if (editingDocument) {
|
||||||
|
const result = await handleUpdate(editingDocument.id, data);
|
||||||
|
if (result.success) {
|
||||||
|
setEditingDocument(null);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDownload = async (doc: TrusteeDocument) => {
|
// Handle delete
|
||||||
|
const handleDeleteDoc = async (doc: TrusteeDocument) => {
|
||||||
|
if (window.confirm(`Dokument "${doc.documentName}" wirklich löschen?`)) {
|
||||||
|
removeOptimistically(doc.id);
|
||||||
|
const success = await handleDelete(doc.id);
|
||||||
|
if (!success) {
|
||||||
|
refetch(); // Revert on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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<TrusteeDocument>, row: TrusteeDocument) => {
|
||||||
|
updateOptimistically(itemId, updateData);
|
||||||
|
const result = await handleUpdate(itemId, { ...row, ...updateData });
|
||||||
|
if (!result.success) {
|
||||||
|
refetch(); // Revert on error
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.listView}>
|
<div className={styles.adminPage}>
|
||||||
{/* Toolbar */}
|
<div className={styles.errorContainer}>
|
||||||
<div className={styles.toolbar}>
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.adminPage}>
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<div>
|
||||||
|
<p className={styles.pageSubtitle}>Belege und Dokumente verwalten</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||||
|
</button>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className={styles.primaryButton} onClick={onCreate}>
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={handleCreateClick}
|
||||||
|
>
|
||||||
+ Neues Dokument
|
+ Neues Dokument
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
</div>
|
||||||
Aktualisieren
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabelle */}
|
<div className={styles.tableContainer}>
|
||||||
{documents.length === 0 ? (
|
{loading && (!documents || documents.length === 0) ? (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<div className={styles.spinner} />
|
||||||
|
<span>Lade Dokumente...</span>
|
||||||
|
</div>
|
||||||
|
) : !documents || documents.length === 0 ? (
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<p>Keine Dokumente vorhanden.</p>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className={styles.dataTable}>
|
<FormGeneratorTable
|
||||||
<thead>
|
data={documents}
|
||||||
<tr>
|
columns={columns}
|
||||||
<th>Name</th>
|
loading={loading}
|
||||||
<th>Typ</th>
|
pagination={true}
|
||||||
<th>Vertrag</th>
|
pageSize={25}
|
||||||
<th>Aktionen</th>
|
searchable={true}
|
||||||
</tr>
|
filterable={true}
|
||||||
</thead>
|
sortable={true}
|
||||||
<tbody>
|
selectable={false}
|
||||||
{documents.map((doc) => (
|
actionButtons={[
|
||||||
<tr key={doc.id}>
|
...(canUpdate ? [{
|
||||||
<td>{doc.documentName}</td>
|
type: 'edit' as const,
|
||||||
<td>
|
onAction: handleEditClick,
|
||||||
<span className={styles.badge}>
|
title: 'Bearbeiten',
|
||||||
{getMimeTypeLabel(doc.documentMimeType)}
|
}] : []),
|
||||||
</span>
|
...(canDelete ? [{
|
||||||
</td>
|
type: 'delete' as const,
|
||||||
<td>{getLabelFast('contracts', doc.contractId)}</td>
|
title: 'Löschen',
|
||||||
<td className={styles.actions}>
|
loading: (row: TrusteeDocument) => deletingItems.has(row.id),
|
||||||
<button
|
}] : []),
|
||||||
className={styles.iconButton}
|
]}
|
||||||
title="Herunterladen"
|
customActions={[
|
||||||
onClick={() => onDownload(doc)}
|
{
|
||||||
disabled={downloading === doc.id}
|
id: 'download',
|
||||||
>
|
icon: <FaDownload />,
|
||||||
{downloading === doc.id ? '...' : '⬇️'}
|
onClick: handleDownload,
|
||||||
</button>
|
title: 'Herunterladen',
|
||||||
{canUpdate && (
|
loading: (row: TrusteeDocument) => downloadingId === row.id,
|
||||||
<button
|
},
|
||||||
className={styles.iconButton}
|
]}
|
||||||
title="Bearbeiten"
|
onDelete={handleDeleteDoc}
|
||||||
onClick={() => onEdit(doc)}
|
hookData={{
|
||||||
>
|
refetch,
|
||||||
✏️
|
permissions,
|
||||||
</button>
|
pagination,
|
||||||
)}
|
handleDelete,
|
||||||
{canDelete && (
|
handleInlineUpdate,
|
||||||
<button
|
updateOptimistically,
|
||||||
className={styles.iconButton}
|
}}
|
||||||
title="Löschen"
|
emptyMessage="Keine Dokumente gefunden"
|
||||||
onClick={() => onDelete(doc.id)}
|
/>
|
||||||
disabled={deletingItems.has(doc.id)}
|
|
||||||
>
|
|
||||||
{deletingItems.has(doc.id) ? '...' : '🗑️'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
<Popup
|
{(editingDocument || isCreateMode) && (
|
||||||
isOpen={isModalOpen}
|
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
||||||
title={editingDoc ? 'Dokument bearbeiten' : 'Neues Dokument'}
|
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||||
onClose={onCloseModal}
|
<div className={styles.modalHeader}>
|
||||||
size="medium"
|
<h2 className={styles.modalTitle}>
|
||||||
|
{isCreateMode ? 'Neues Dokument' : 'Dokument bearbeiten'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
className={styles.modalClose}
|
||||||
|
onClick={handleCloseModal}
|
||||||
>
|
>
|
||||||
{formError && (
|
✕
|
||||||
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
|
</button>
|
||||||
{formError}
|
</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>
|
||||||
)}
|
)}
|
||||||
<TrusteeEditForm<TrusteeDocument>
|
|
||||||
initialData={editingDoc || { documentMimeType: 'application/pdf' }}
|
|
||||||
fields={fields}
|
|
||||||
onSave={onSave}
|
|
||||||
onCancel={onCloseModal}
|
|
||||||
isSaving={creatingItem}
|
|
||||||
isEdit={!!editingDoc}
|
|
||||||
saveLabel={editingDoc ? 'Aktualisieren' : 'Erstellen'}
|
|
||||||
/>
|
|
||||||
</Popup>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
180
src/pages/views/trustee/TrusteeInstanceRolesView.tsx
Normal file
180
src/pages/views/trustee/TrusteeInstanceRolesView.tsx
Normal 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;
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
removeOptimistically,
|
||||||
|
} = useTrusteePositionDocuments();
|
||||||
|
|
||||||
// Modal State
|
// Operations hook
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const {
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
handleDelete,
|
||||||
const [contractOptions, setContractOptions] = useState<Array<{value: string, label: string}>>([]);
|
handleCreate,
|
||||||
|
deletingItems,
|
||||||
|
creatingItem,
|
||||||
|
} = useTrusteePositionDocumentOperations();
|
||||||
|
|
||||||
// Feld-Konfiguration für das Formular
|
// Modal state
|
||||||
const fields: FieldConfig[] = useMemo(() => [
|
const [isCreateMode, setIsCreateMode] = useState(false);
|
||||||
{
|
|
||||||
key: 'organisationId',
|
|
||||||
label: 'Organisation',
|
|
||||||
type: 'enum',
|
|
||||||
required: true,
|
|
||||||
optionsReference: 'organisations',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'contractId',
|
|
||||||
label: 'Vertrag',
|
|
||||||
type: 'enum',
|
|
||||||
required: true,
|
|
||||||
options: contractOptions,
|
|
||||||
dependsOn: 'organisationId',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'positionId',
|
|
||||||
label: 'Position',
|
|
||||||
type: 'enum',
|
|
||||||
required: true,
|
|
||||||
optionsReference: 'positions',
|
|
||||||
helpText: 'Die Buchungsposition, der ein Beleg zugewiesen werden soll',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'documentId',
|
|
||||||
label: 'Dokument',
|
|
||||||
type: 'enum',
|
|
||||||
required: true,
|
|
||||||
optionsReference: 'documents',
|
|
||||||
helpText: 'Der Beleg, der der Position zugewiesen werden soll',
|
|
||||||
},
|
|
||||||
], [contractOptions]);
|
|
||||||
|
|
||||||
if (loading || optionsLoading) {
|
// Initial fetch
|
||||||
return <div className={styles.loading}>Lade Verknüpfungen...</div>;
|
useEffect(() => {
|
||||||
|
if (instanceId) {
|
||||||
|
refetch();
|
||||||
}
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
// Generate columns from attributes
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return (attributes || []).map(attr => ({
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: attr.type as any,
|
||||||
|
sortable: attr.sortable !== false,
|
||||||
|
filterable: attr.filterable !== false,
|
||||||
|
searchable: attr.searchable !== false,
|
||||||
|
width: attr.width || 150,
|
||||||
|
minWidth: attr.minWidth || 100,
|
||||||
|
maxWidth: attr.maxWidth || 400,
|
||||||
|
}));
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
const canCreate = permissions?.create !== 'n';
|
||||||
|
const canDelete = permissions?.delete !== 'n';
|
||||||
|
|
||||||
|
// Handle create click
|
||||||
|
const handleCreateClick = () => {
|
||||||
|
setIsCreateMode(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle form submit
|
||||||
|
const handleFormSubmit = async (data: Partial<TrusteePositionDocument>) => {
|
||||||
|
const result = await handleCreate(data);
|
||||||
|
if (result.success) {
|
||||||
|
setIsCreateMode(false);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle delete
|
||||||
|
const handleDeleteLink = async (link: TrusteePositionDocument) => {
|
||||||
|
if (window.confirm('Verknüpfung wirklich entfernen?')) {
|
||||||
|
removeOptimistically(link.id);
|
||||||
|
const success = await handleDelete(link.id);
|
||||||
|
if (!success) {
|
||||||
|
refetch(); // Revert on error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsCreateMode(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return <div className={styles.error}>Fehler: {error}</div>;
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDelete = async (linkId: string) => {
|
|
||||||
if (window.confirm('Verknüpfung wirklich entfernen?')) {
|
|
||||||
const success = await handleDelete(linkId);
|
|
||||||
if (success) {
|
|
||||||
refetch();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCreate = () => {
|
|
||||||
setFormError(null);
|
|
||||||
setContractOptions([]);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCloseModal = () => {
|
|
||||||
setIsModalOpen(false);
|
|
||||||
setFormError(null);
|
|
||||||
setContractOptions([]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onSave = async (data: Partial<TrusteePositionDocument>) => {
|
|
||||||
setFormError(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Gruppiere nach Position für bessere Übersicht
|
|
||||||
const groupedByPosition = useMemo(() => {
|
|
||||||
const grouped: Record<string, TrusteePositionDocument[]> = {};
|
|
||||||
links.forEach(link => {
|
|
||||||
if (!grouped[link.positionId]) {
|
|
||||||
grouped[link.positionId] = [];
|
|
||||||
}
|
|
||||||
grouped[link.positionId].push(link);
|
|
||||||
});
|
|
||||||
return grouped;
|
|
||||||
}, [links]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.listView}>
|
<div className={styles.adminPage}>
|
||||||
{/* Toolbar */}
|
<div className={styles.pageHeader}>
|
||||||
<div className={styles.toolbar}>
|
<div>
|
||||||
|
<p className={styles.pageSubtitle}>Belege mit Buchungspositionen verknüpfen</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||||
|
</button>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className={styles.primaryButton} onClick={onCreate}>
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={handleCreateClick}
|
||||||
|
>
|
||||||
+ Neue Verknüpfung
|
+ Neue Verknüpfung
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
</div>
|
||||||
Aktualisieren
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info */}
|
<div className={styles.tableContainer}>
|
||||||
<div className={styles.muted} style={{ fontSize: '0.8125rem', padding: '0.5rem 0' }}>
|
{loading && (!links || links.length === 0) ? (
|
||||||
Hier verknüpfen Sie Belege (Dokumente) mit Buchungspositionen.
|
<div className={styles.loadingContainer}>
|
||||||
|
<div className={styles.spinner} />
|
||||||
|
<span>Lade Verknüpfungen...</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : !links || links.length === 0 ? (
|
||||||
{/* Tabelle */}
|
|
||||||
{links.length === 0 ? (
|
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<p>Keine Verknüpfungen vorhanden.</p>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className={styles.dataTable}>
|
<FormGeneratorTable
|
||||||
<thead>
|
data={links}
|
||||||
<tr>
|
columns={columns}
|
||||||
<th>Position</th>
|
loading={loading}
|
||||||
<th>Dokument</th>
|
pagination={true}
|
||||||
<th>Vertrag</th>
|
pageSize={25}
|
||||||
<th>Aktionen</th>
|
searchable={true}
|
||||||
</tr>
|
filterable={true}
|
||||||
</thead>
|
sortable={true}
|
||||||
<tbody>
|
selectable={false}
|
||||||
{links.map((link) => (
|
actionButtons={[
|
||||||
<tr key={link.id}>
|
...(canDelete ? [{
|
||||||
<td>{getLabelFast('positions', link.positionId)}</td>
|
type: 'delete' as const,
|
||||||
<td>{getLabelFast('documents', link.documentId)}</td>
|
title: 'Verknüpfung entfernen',
|
||||||
<td>{getLabelFast('contracts', link.contractId)}</td>
|
loading: (row: TrusteePositionDocument) => deletingItems.has(row.id),
|
||||||
<td className={styles.actions}>
|
}] : []),
|
||||||
{canDelete && (
|
]}
|
||||||
<button
|
onDelete={handleDeleteLink}
|
||||||
className={styles.iconButton}
|
hookData={{
|
||||||
title="Verknüpfung entfernen"
|
refetch,
|
||||||
onClick={() => onDelete(link.id)}
|
permissions,
|
||||||
disabled={deletingItems.has(link.id)}
|
pagination,
|
||||||
>
|
handleDelete,
|
||||||
{deletingItems.has(link.id) ? '...' : '🗑️'}
|
}}
|
||||||
</button>
|
emptyMessage="Keine Verknüpfungen gefunden"
|
||||||
)}
|
/>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create Modal */}
|
{/* Create Modal */}
|
||||||
<Popup
|
{isCreateMode && (
|
||||||
isOpen={isModalOpen}
|
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
||||||
title="Neue Verknüpfung erstellen"
|
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||||
onClose={onCloseModal}
|
<div className={styles.modalHeader}>
|
||||||
size="medium"
|
<h2 className={styles.modalTitle}>Neue Verknüpfung erstellen</h2>
|
||||||
|
<button
|
||||||
|
className={styles.modalClose}
|
||||||
|
onClick={handleCloseModal}
|
||||||
>
|
>
|
||||||
{formError && (
|
✕
|
||||||
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
|
</button>
|
||||||
{formError}
|
</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>
|
||||||
)}
|
)}
|
||||||
<TrusteeEditForm<TrusteePositionDocument>
|
|
||||||
initialData={{}}
|
|
||||||
fields={fields}
|
|
||||||
onSave={onSave}
|
|
||||||
onCancel={onCloseModal}
|
|
||||||
isSaving={creatingItem}
|
|
||||||
isEdit={false}
|
|
||||||
saveLabel="Verknüpfung erstellen"
|
|
||||||
/>
|
|
||||||
</Popup>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,307 +2,267 @@
|
||||||
* 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,
|
||||||
|
attributes,
|
||||||
|
permissions,
|
||||||
|
pagination,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
fetchById,
|
||||||
|
updateOptimistically,
|
||||||
|
removeOptimistically,
|
||||||
|
} = useTrusteePositions();
|
||||||
|
|
||||||
// Modal State
|
// Operations hook
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
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}>>([]);
|
|
||||||
|
|
||||||
// Währungs-Options
|
// Initial fetch
|
||||||
const currencyOptions = [
|
useEffect(() => {
|
||||||
{ value: 'CHF', label: 'CHF' },
|
if (instanceId) {
|
||||||
{ value: 'EUR', label: 'EUR' },
|
|
||||||
{ value: 'USD', label: 'USD' },
|
|
||||||
{ value: 'GBP', label: 'GBP' },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Feld-Konfiguration für das Formular
|
|
||||||
const fields: FieldConfig[] = useMemo(() => [
|
|
||||||
{
|
|
||||||
key: 'organisationId',
|
|
||||||
label: 'Organisation',
|
|
||||||
type: 'enum',
|
|
||||||
required: true,
|
|
||||||
optionsReference: 'organisations',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'contractId',
|
|
||||||
label: 'Vertrag',
|
|
||||||
type: 'enum',
|
|
||||||
required: true,
|
|
||||||
options: contractOptions,
|
|
||||||
dependsOn: 'organisationId',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'valuta',
|
|
||||||
label: 'Valutadatum',
|
|
||||||
type: 'date',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'company',
|
|
||||||
label: 'Firma',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: 'Name des Lieferanten/Empfängers',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'desc',
|
|
||||||
label: 'Beschreibung',
|
|
||||||
type: 'textarea',
|
|
||||||
placeholder: 'Beschreibung der Position',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'tags',
|
|
||||||
label: 'Tags',
|
|
||||||
type: 'string',
|
|
||||||
placeholder: 'Komma-getrennte Tags',
|
|
||||||
helpText: 'z.B. Reise, Spesen, IT',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bookingCurrency',
|
|
||||||
label: 'Buchungswährung',
|
|
||||||
type: 'enum',
|
|
||||||
required: true,
|
|
||||||
options: currencyOptions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'bookingAmount',
|
|
||||||
label: 'Buchungsbetrag',
|
|
||||||
type: 'number',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'originalCurrency',
|
|
||||||
label: 'Originalwährung',
|
|
||||||
type: 'enum',
|
|
||||||
required: true,
|
|
||||||
options: currencyOptions,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'originalAmount',
|
|
||||||
label: 'Originalbetrag',
|
|
||||||
type: 'number',
|
|
||||||
required: true,
|
|
||||||
helpText: 'Betrag in Originalwährung (keine automatische Umrechnung)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'vatPercentage',
|
|
||||||
label: 'MwSt %',
|
|
||||||
type: 'number',
|
|
||||||
helpText: 'MwSt-Satz in Prozent (z.B. 8.1)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'vatAmount',
|
|
||||||
label: 'MwSt Betrag',
|
|
||||||
type: 'number',
|
|
||||||
helpText: 'Wird automatisch berechnet (kann manuell überschrieben werden)',
|
|
||||||
},
|
|
||||||
], [contractOptions]);
|
|
||||||
|
|
||||||
if (loading || optionsLoading) {
|
|
||||||
return <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();
|
refetch();
|
||||||
}
|
}
|
||||||
|
}, [instanceId]);
|
||||||
|
|
||||||
|
// Generate columns from attributes
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
return (attributes || []).map(attr => ({
|
||||||
|
key: attr.name,
|
||||||
|
label: attr.label || attr.name,
|
||||||
|
type: attr.type as any,
|
||||||
|
sortable: attr.sortable !== false,
|
||||||
|
filterable: attr.filterable !== false,
|
||||||
|
searchable: attr.searchable !== false,
|
||||||
|
width: attr.width || 150,
|
||||||
|
minWidth: attr.minWidth || 100,
|
||||||
|
maxWidth: attr.maxWidth || 400,
|
||||||
|
}));
|
||||||
|
}, [attributes]);
|
||||||
|
|
||||||
|
// Check permissions
|
||||||
|
const canCreate = permissions?.create !== 'n';
|
||||||
|
const canUpdate = permissions?.update !== 'n';
|
||||||
|
const canDelete = permissions?.delete !== 'n';
|
||||||
|
|
||||||
|
// Handle edit click
|
||||||
|
const handleEditClick = async (pos: TrusteePosition) => {
|
||||||
|
const fullPos = await fetchById(pos.id);
|
||||||
|
if (fullPos) {
|
||||||
|
setEditingPosition(fullPos);
|
||||||
|
setIsCreateMode(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const onEdit = async (pos: TrusteePosition) => {
|
// 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 handleUpdate(editingPosition.id, processedData);
|
|
||||||
if (!result.success) {
|
|
||||||
setFormError(result.error || 'Fehler beim Aktualisieren');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const result = await handleCreate(processedData);
|
const result = await handleCreate(processedData);
|
||||||
if (!result.success) {
|
if (result.success) {
|
||||||
setFormError(result.error || 'Fehler beim Erstellen');
|
setIsCreateMode(false);
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onCloseModal();
|
|
||||||
refetch();
|
refetch();
|
||||||
} catch (err: any) {
|
}
|
||||||
setFormError(err.message || 'Ein Fehler ist aufgetreten');
|
} else if (editingPosition) {
|
||||||
|
const result = await handleUpdate(editingPosition.id, processedData);
|
||||||
|
if (result.success) {
|
||||||
|
setEditingPosition(null);
|
||||||
|
refetch();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setEditingPosition(null);
|
||||||
|
setIsCreateMode(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.listView}>
|
<div className={styles.adminPage}>
|
||||||
{/* Toolbar */}
|
<div className={styles.pageHeader}>
|
||||||
<div className={styles.toolbar}>
|
<div>
|
||||||
|
<p className={styles.pageSubtitle}>Buchungspositionen verwalten</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerActions}>
|
||||||
|
<button
|
||||||
|
className={styles.secondaryButton}
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||||
|
</button>
|
||||||
{canCreate && (
|
{canCreate && (
|
||||||
<button className={styles.primaryButton} onClick={onCreate}>
|
<button
|
||||||
|
className={styles.primaryButton}
|
||||||
|
onClick={handleCreateClick}
|
||||||
|
>
|
||||||
+ Neue Position
|
+ Neue Position
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
</div>
|
||||||
Aktualisieren
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabelle */}
|
<div className={styles.tableContainer}>
|
||||||
{positions.length === 0 ? (
|
{loading && (!positions || positions.length === 0) ? (
|
||||||
|
<div className={styles.loadingContainer}>
|
||||||
|
<div className={styles.spinner} />
|
||||||
|
<span>Lade Positionen...</span>
|
||||||
|
</div>
|
||||||
|
) : !positions || positions.length === 0 ? (
|
||||||
<div className={styles.emptyState}>
|
<div className={styles.emptyState}>
|
||||||
<p>Keine Positionen vorhanden.</p>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<table className={styles.dataTable}>
|
<FormGeneratorTable
|
||||||
<thead>
|
data={positions}
|
||||||
<tr>
|
columns={columns}
|
||||||
<th>Valuta</th>
|
loading={loading}
|
||||||
<th>Firma</th>
|
pagination={true}
|
||||||
<th>Beschreibung</th>
|
pageSize={25}
|
||||||
<th>Vertrag</th>
|
searchable={true}
|
||||||
<th className={styles.alignRight}>Betrag</th>
|
filterable={true}
|
||||||
<th className={styles.alignRight}>MwSt</th>
|
sortable={true}
|
||||||
<th>Aktionen</th>
|
selectable={false}
|
||||||
</tr>
|
actionButtons={[
|
||||||
</thead>
|
...(canUpdate ? [{
|
||||||
<tbody>
|
type: 'edit' as const,
|
||||||
{positions.map((pos) => (
|
onAction: handleEditClick,
|
||||||
<tr key={pos.id}>
|
title: 'Bearbeiten',
|
||||||
<td>{formatDate(pos.valuta)}</td>
|
}] : []),
|
||||||
<td>{pos.company || '-'}</td>
|
...(canDelete ? [{
|
||||||
<td className={styles.truncate} title={pos.desc}>
|
type: 'delete' as const,
|
||||||
{pos.desc || '-'}
|
title: 'Löschen',
|
||||||
</td>
|
loading: (row: TrusteePosition) => deletingItems.has(row.id),
|
||||||
<td>{getLabelFast('contracts', pos.contractId)}</td>
|
}] : []),
|
||||||
<td className={styles.alignRight}>
|
]}
|
||||||
{formatAmount(pos.bookingAmount, pos.bookingCurrency)}
|
onDelete={handleDeletePos}
|
||||||
</td>
|
hookData={{
|
||||||
<td className={styles.alignRight}>
|
refetch,
|
||||||
{pos.vatPercentage > 0 ? (
|
permissions,
|
||||||
<span title={formatAmount(pos.vatAmount, pos.bookingCurrency)}>
|
pagination,
|
||||||
{pos.vatPercentage}%
|
handleDelete,
|
||||||
</span>
|
handleInlineUpdate,
|
||||||
) : (
|
updateOptimistically,
|
||||||
<span className={styles.muted}>-</span>
|
}}
|
||||||
)}
|
emptyMessage="Keine Positionen gefunden"
|
||||||
</td>
|
/>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
<Popup
|
{(editingPosition || isCreateMode) && (
|
||||||
isOpen={isModalOpen}
|
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
||||||
title={editingPosition ? 'Position bearbeiten' : 'Neue Position'}
|
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||||
onClose={onCloseModal}
|
<div className={styles.modalHeader}>
|
||||||
size="large"
|
<h2 className={styles.modalTitle}>
|
||||||
|
{isCreateMode ? 'Neue Position' : 'Position bearbeiten'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
className={styles.modalClose}
|
||||||
|
onClick={handleCloseModal}
|
||||||
>
|
>
|
||||||
{formError && (
|
✕
|
||||||
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
|
</button>
|
||||||
{formError}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className={styles.modalContent}>
|
||||||
<TrusteeEditForm<TrusteePosition>
|
{formAttributes.length === 0 ? (
|
||||||
initialData={editingPosition || {
|
<div className={styles.loadingContainer}>
|
||||||
|
<div className={styles.spinner} />
|
||||||
|
<span>Lade Formular...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<FormGeneratorForm
|
||||||
|
attributes={formAttributes}
|
||||||
|
data={editingPosition || {
|
||||||
bookingCurrency: 'CHF',
|
bookingCurrency: 'CHF',
|
||||||
originalCurrency: 'CHF',
|
originalCurrency: 'CHF',
|
||||||
bookingAmount: 0,
|
bookingAmount: 0,
|
||||||
|
|
@ -310,14 +270,17 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
vatPercentage: 0,
|
vatPercentage: 0,
|
||||||
vatAmount: 0,
|
vatAmount: 0,
|
||||||
}}
|
}}
|
||||||
fields={fields}
|
mode={isCreateMode ? 'create' : 'edit'}
|
||||||
onSave={onSave}
|
onSubmit={handleFormSubmit}
|
||||||
onCancel={onCloseModal}
|
onCancel={handleCloseModal}
|
||||||
isSaving={creatingItem}
|
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
||||||
isEdit={!!editingPosition}
|
cancelButtonText="Abbrechen"
|
||||||
saveLabel={editingPosition ? 'Aktualisieren' : 'Erstellen'}
|
|
||||||
/>
|
/>
|
||||||
</Popup>
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue