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="files" element={<FeatureViewPage view="files" />} />
|
||||
<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 */}
|
||||
<Route path="*" element={<FeatureViewPage view="not-found" />} />
|
||||
|
|
|
|||
|
|
@ -37,6 +37,8 @@ interface AccessRulesEditorProps {
|
|||
isTemplate?: boolean;
|
||||
readOnly?: boolean;
|
||||
onSave?: () => void;
|
||||
apiBasePath?: string;
|
||||
mandateId?: string;
|
||||
}
|
||||
|
||||
type TabType = 'DATA' | 'UI' | 'RESOURCE' | 'JSON';
|
||||
|
|
@ -409,6 +411,8 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
|||
isTemplate = false,
|
||||
readOnly = false,
|
||||
onSave,
|
||||
apiBasePath = '/api/rbac',
|
||||
mandateId,
|
||||
}) => {
|
||||
const {
|
||||
rules,
|
||||
|
|
@ -421,7 +425,7 @@ export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
|||
updateRuleLocally,
|
||||
addRuleLocally,
|
||||
removeRuleLocally,
|
||||
} = useAccessRules(roleId);
|
||||
} = useAccessRules(roleId, apiBasePath, mandateId);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('DATA');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ function instanceToTreeNode(
|
|||
return {
|
||||
id: instance.id,
|
||||
label: instance.instanceLabel,
|
||||
badge: instance.userRole,
|
||||
// Note: badge für userRole entfernt - ein User kann mehrere Rollen haben
|
||||
children,
|
||||
defaultExpanded: false,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -216,7 +216,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
|||
<span className={styles.chevronSpacer} />
|
||||
)}
|
||||
{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 && (
|
||||
<span
|
||||
className={`${styles.nodeBadge} ${node.badgeVariant ? styles[`badge${node.badgeVariant.charAt(0).toUpperCase() + node.badgeVariant.slice(1)}`] : ''}`}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
/**
|
||||
* useAccessRules Hook
|
||||
*
|
||||
* Hook for managing RBAC AccessRules for a specific role.
|
||||
* Provides CRUD operations for access rules.
|
||||
* 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';
|
||||
|
|
@ -12,51 +12,14 @@ import api from '../api';
|
|||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type AccessLevel = 'n' | 'm' | 'g' | 'a';
|
||||
export type RuleContext = 'DATA' | 'UI' | 'RESOURCE';
|
||||
|
||||
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[];
|
||||
}
|
||||
export type AccessLevel = 'n' | 'm' | 'g' | 'a' | null;
|
||||
|
||||
// =============================================================================
|
||||
// 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: 'm', label: 'Eigene', color: '#d69e2e' },
|
||||
{ value: 'g', label: 'Gruppe', color: '#3182ce' },
|
||||
|
|
@ -75,126 +38,159 @@ export const getAccessLevelColor = (level: AccessLevel | null): string => {
|
|||
return option?.color || '#718096';
|
||||
};
|
||||
|
||||
export interface AccessRule {
|
||||
id: string;
|
||||
roleId: string;
|
||||
context: RuleContext;
|
||||
item: string | null;
|
||||
view: boolean;
|
||||
read: AccessLevel;
|
||||
create: AccessLevel;
|
||||
update: AccessLevel;
|
||||
delete: AccessLevel;
|
||||
}
|
||||
|
||||
export interface AccessRuleCreate {
|
||||
context: RuleContext;
|
||||
item?: string | null;
|
||||
view?: boolean;
|
||||
read?: AccessLevel;
|
||||
create?: AccessLevel;
|
||||
update?: AccessLevel;
|
||||
delete?: AccessLevel;
|
||||
}
|
||||
|
||||
interface GroupedRules {
|
||||
DATA: AccessRule[];
|
||||
UI: AccessRule[];
|
||||
RESOURCE: AccessRule[];
|
||||
}
|
||||
|
||||
interface SaveResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HOOK
|
||||
// =============================================================================
|
||||
|
||||
export function useAccessRules(roleId: string | null) {
|
||||
export function useAccessRules(roleId: string, apiBasePath: string = '/api/rbac') {
|
||||
const [rules, setRules] = useState<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/');
|
||||
|
||||
/**
|
||||
* Fetch all rules for the role
|
||||
*/
|
||||
const fetchRules = useCallback(async (): Promise<AccessRule[]> => {
|
||||
if (!roleId) {
|
||||
setRules([]);
|
||||
return [];
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await api.get(`/api/rbac/roles/${roleId}/rules`);
|
||||
const fetchedRules = Array.isArray(response.data) ? response.data : response.data.rules || [];
|
||||
// Different endpoint structure for instance roles vs system roles
|
||||
const endpoint = isInstanceApi
|
||||
? `${apiBasePath}/rules`
|
||||
: `${apiBasePath}/rules/by-role/${roleId}`;
|
||||
|
||||
const response = await api.get(endpoint);
|
||||
const fetchedRules = response.data?.items || response.data || [];
|
||||
setRules(fetchedRules);
|
||||
return fetchedRules;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch access rules';
|
||||
setError(errorMessage);
|
||||
setRules([]);
|
||||
const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Laden der Regeln';
|
||||
setError(errorMsg);
|
||||
console.error('Error fetching rules:', err);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [roleId]);
|
||||
}, [roleId, apiBasePath, isInstanceApi]);
|
||||
|
||||
/**
|
||||
* Save all rules for the role (bulk update)
|
||||
* Save all rules for the role
|
||||
*/
|
||||
const saveRules = useCallback(async (newRules: AccessRule[]): Promise<{ success: boolean; error?: string }> => {
|
||||
if (!roleId) {
|
||||
return { success: false, error: 'No role selected' };
|
||||
}
|
||||
|
||||
const saveRules = useCallback(async (rulesToSave: AccessRule[]): Promise<SaveResult> => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await api.put(`/api/rbac/roles/${roleId}/rules`, { rules: newRules });
|
||||
setRules(newRules);
|
||||
// Different endpoint structure for instance roles vs system roles
|
||||
const rulesEndpoint = isInstanceApi
|
||||
? `${apiBasePath}/rules`
|
||||
: `${apiBasePath}/rules/by-role/${roleId}`;
|
||||
|
||||
// Get current rules from server
|
||||
const currentResponse = await api.get(rulesEndpoint);
|
||||
const currentRules: AccessRule[] = currentResponse.data?.items || currentResponse.data || [];
|
||||
const currentRuleIds = new Set(currentRules.map(r => r.id));
|
||||
|
||||
// Determine changes
|
||||
const newRules = rulesToSave.filter(r => r.id.startsWith('temp-'));
|
||||
const existingRules = rulesToSave.filter(r => !r.id.startsWith('temp-'));
|
||||
const deletedRuleIds = [...currentRuleIds].filter(
|
||||
id => !existingRules.some(r => r.id === id)
|
||||
);
|
||||
|
||||
// Delete removed rules
|
||||
for (const deletedId of deletedRuleIds) {
|
||||
const deleteEndpoint = isInstanceApi
|
||||
? `${apiBasePath}/rules/${deletedId}`
|
||||
: `${apiBasePath}/rules/${deletedId}`;
|
||||
await api.delete(deleteEndpoint);
|
||||
}
|
||||
|
||||
// Create new rules
|
||||
for (const rule of newRules) {
|
||||
const createEndpoint = isInstanceApi
|
||||
? `${apiBasePath}/rules`
|
||||
: `${apiBasePath}/rules`;
|
||||
await api.post(createEndpoint, {
|
||||
roleId,
|
||||
context: rule.context,
|
||||
item: rule.item,
|
||||
view: rule.view,
|
||||
read: rule.read,
|
||||
create: rule.create,
|
||||
update: rule.update,
|
||||
delete: rule.delete,
|
||||
});
|
||||
}
|
||||
|
||||
// Update existing rules
|
||||
for (const rule of existingRules) {
|
||||
const original = currentRules.find(r => r.id === rule.id);
|
||||
if (original && JSON.stringify(original) !== JSON.stringify(rule)) {
|
||||
const updateEndpoint = isInstanceApi
|
||||
? `${apiBasePath}/rules/${rule.id}`
|
||||
: `${apiBasePath}/rules/${rule.id}`;
|
||||
await api.put(updateEndpoint, {
|
||||
view: rule.view,
|
||||
read: rule.read,
|
||||
create: rule.create,
|
||||
update: rule.update,
|
||||
delete: rule.delete,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh rules
|
||||
await fetchRules();
|
||||
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to save access rules';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
const errorMsg = err.response?.data?.detail || err.message || 'Fehler beim Speichern';
|
||||
setError(errorMsg);
|
||||
console.error('Error saving rules:', err);
|
||||
return { success: false, error: errorMsg };
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [roleId]);
|
||||
|
||||
/**
|
||||
* Create a new rule
|
||||
*/
|
||||
const createRule = useCallback(async (ruleData: AccessRuleCreate): Promise<{ success: boolean; data?: AccessRule; error?: string }> => {
|
||||
if (!roleId) {
|
||||
return { success: false, error: 'No role selected' };
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post(`/api/rbac/roles/${roleId}/rules`, ruleData);
|
||||
const newRule = response.data;
|
||||
setRules(prev => [...prev, newRule]);
|
||||
return { success: true, data: newRule };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to create access rule';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [roleId]);
|
||||
|
||||
/**
|
||||
* Update an existing rule
|
||||
*/
|
||||
const updateRule = useCallback(async (ruleId: string, updates: AccessRuleUpdate): Promise<{ success: boolean; error?: string }> => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.patch(`/api/rbac/rules/${ruleId}`, updates);
|
||||
setRules(prev => prev.map(r => r.id === ruleId ? { ...r, ...response.data } : r));
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to update access rule';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Delete a rule
|
||||
*/
|
||||
const deleteRule = useCallback(async (ruleId: string): Promise<{ success: boolean; error?: string }> => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.delete(`/api/rbac/rules/${ruleId}`);
|
||||
setRules(prev => prev.filter(r => r.id !== ruleId));
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to delete access rule';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
}, [roleId, apiBasePath, isInstanceApi, fetchRules]);
|
||||
|
||||
/**
|
||||
* Get rules grouped by context
|
||||
|
|
@ -208,21 +204,23 @@ export function useAccessRules(roleId: string | null) {
|
|||
}, [rules]);
|
||||
|
||||
/**
|
||||
* Update rule locally (for optimistic updates)
|
||||
* Update a rule locally (not saved until saveRules is called)
|
||||
*/
|
||||
const updateRuleLocally = useCallback((ruleId: string, updates: Partial<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) => {
|
||||
setRules(prev => [...prev, rule]);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Remove rule locally (for optimistic updates)
|
||||
* Remove a rule locally (not saved until saveRules is called)
|
||||
*/
|
||||
const removeRuleLocally = useCallback((ruleId: string) => {
|
||||
setRules(prev => prev.filter(r => r.id !== ruleId));
|
||||
|
|
@ -235,9 +233,6 @@ export function useAccessRules(roleId: string | null) {
|
|||
error,
|
||||
fetchRules,
|
||||
saveRules,
|
||||
createRule,
|
||||
updateRule,
|
||||
deleteRule,
|
||||
getGroupedRules,
|
||||
updateRuleLocally,
|
||||
addRuleLocally,
|
||||
|
|
|
|||
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>
|
||||
</div>
|
||||
<div className={styles.roleIndicator}>
|
||||
<span className={styles.roleBadge}>{instance?.userRole}</span>
|
||||
<span className={styles.roleBadge}>{instance?.userRoles?.join(', ') || '-'}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const InstanceCard: React.FC<InstanceCardProps> = ({ instance, featureLabel }) =
|
|||
<div className={styles.cardContent}>
|
||||
<div className={styles.cardHeader}>
|
||||
<span className={styles.featureLabel}>{featureLabel}</span>
|
||||
<span className={styles.roleBadge}>{instance.userRole}</span>
|
||||
<span className={styles.roleBadge}>{instance.userRoles?.join(', ') || '-'}</span>
|
||||
</div>
|
||||
<h3 className={styles.instanceLabel}>{instance.instanceLabel}</h3>
|
||||
<p className={styles.mandateName}>{instance.mandateName}</p>
|
||||
|
|
|
|||
|
|
@ -11,13 +11,12 @@ import { useCanViewFeatureView } from '../hooks/useInstancePermissions';
|
|||
import { getLabel, FEATURE_REGISTRY } from '../types/mandate';
|
||||
|
||||
// Trustee Views
|
||||
import { TrusteeContractsView } from './views/trustee/TrusteeContractsView';
|
||||
import { TrusteeOrganisationsView } from './views/trustee/TrusteeOrganisationsView';
|
||||
// Note: TrusteeOrganisationsView and TrusteeContractsView removed - Feature-Instanz = Organisation
|
||||
import { TrusteeDocumentsView } from './views/trustee/TrusteeDocumentsView';
|
||||
import { TrusteePositionsView } from './views/trustee/TrusteePositionsView';
|
||||
import { TrusteeRolesView } from './views/trustee/TrusteeRolesView';
|
||||
import { TrusteeAccessView } from './views/trustee/TrusteeAccessView';
|
||||
import { TrusteePositionDocumentsView } from './views/trustee/TrusteePositionDocumentsView';
|
||||
import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView';
|
||||
import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView';
|
||||
|
||||
import styles from './FeatureView.module.css';
|
||||
|
||||
|
|
@ -78,12 +77,10 @@ type ViewComponent = React.FC;
|
|||
const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||
trustee: {
|
||||
dashboard: TrusteeDashboardView,
|
||||
organisations: TrusteeOrganisationsView,
|
||||
contracts: TrusteeContractsView,
|
||||
documents: TrusteeDocumentsView,
|
||||
positions: TrusteePositionsView,
|
||||
roles: TrusteeRolesView,
|
||||
access: TrusteeAccessView,
|
||||
'position-documents': TrusteePositionDocumentsView,
|
||||
'instance-roles': TrusteeInstanceRolesView,
|
||||
},
|
||||
chatworkflow: {
|
||||
dashboard: ChatworkflowDashboard,
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@
|
|||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaUserShield, FaCube } from 'react-icons/fa';
|
||||
import { AccessRulesEditor } from '../../components/AccessRules';
|
||||
import { FaPlus, FaSync, FaUserShield, FaCube, FaShieldAlt } from 'react-icons/fa';
|
||||
import api from '../../api';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
|
|
@ -47,6 +48,7 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<FeatureRole | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [permissionsRole, setPermissionsRole] = useState<FeatureRole | null>(null);
|
||||
|
||||
// Load features on mount
|
||||
useEffect(() => {
|
||||
|
|
@ -369,6 +371,14 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
title: 'Rolle löschen',
|
||||
}
|
||||
]}
|
||||
customActions={[
|
||||
{
|
||||
id: 'permissions',
|
||||
icon: <FaShieldAlt />,
|
||||
onClick: (role: FeatureRole) => setPermissionsRole(role),
|
||||
title: 'Berechtigungen verwalten',
|
||||
}
|
||||
]}
|
||||
onDelete={handleDeleteRole}
|
||||
hookData={{
|
||||
refetch: fetchRoles,
|
||||
|
|
@ -441,6 +451,39 @@ export const AdminFeatureRolesPage: React.FC = () => {
|
|||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
* Übersicht/Dashboard für eine Trustee-Instanz
|
||||
* Übersicht/Dashboard für eine Trustee-Instanz.
|
||||
* Zeigt Statistiken über Positionen, Dokumente und Verknüpfungen.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
|
||||
import { useTrusteeOrganisations } from '../../../hooks/useTrustee';
|
||||
import { useTrusteeContracts } from '../../../hooks/useTrustee';
|
||||
import { useTrusteePositions, useTrusteeDocuments, useTrusteePositionDocuments } from '../../../hooks/useTrustee';
|
||||
import styles from './TrusteeViews.module.css';
|
||||
|
||||
export const TrusteeDashboardView: React.FC = () => {
|
||||
const { instance } = useCurrentInstance();
|
||||
const { items: organisations, loading: orgsLoading } = useTrusteeOrganisations();
|
||||
const { items: contracts, loading: contractsLoading } = useTrusteeContracts();
|
||||
const { items: positions, loading: posLoading } = useTrusteePositions();
|
||||
const { items: documents, loading: docsLoading } = useTrusteeDocuments();
|
||||
const { items: links, loading: linksLoading } = useTrusteePositionDocuments();
|
||||
|
||||
const isLoading = orgsLoading || contractsLoading;
|
||||
const isLoading = posLoading || docsLoading || linksLoading;
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardView}>
|
||||
<div className={styles.statsGrid}>
|
||||
{/* Organisationen Card */}
|
||||
{/* Positionen Card */}
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statIcon}>🏢</div>
|
||||
<div className={styles.statIcon}>📊</div>
|
||||
<div className={styles.statContent}>
|
||||
<div className={styles.statValue}>
|
||||
{isLoading ? '...' : organisations.length}
|
||||
{isLoading ? '...' : positions.length}
|
||||
</div>
|
||||
<div className={styles.statLabel}>Organisationen</div>
|
||||
<div className={styles.statLabel}>Positionen</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verträge Card */}
|
||||
{/* Dokumente Card */}
|
||||
<div className={styles.statCard}>
|
||||
<div className={styles.statIcon}>📄</div>
|
||||
<div className={styles.statContent}>
|
||||
<div className={styles.statValue}>
|
||||
{isLoading ? '...' : contracts.length}
|
||||
{isLoading ? '...' : documents.length}
|
||||
</div>
|
||||
<div className={styles.statLabel}>Verträge</div>
|
||||
<div className={styles.statLabel}>Dokumente</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.statIcon}>👤</div>
|
||||
<div className={styles.statContent}>
|
||||
<div className={styles.statValue}>{instance?.userRole || '-'}</div>
|
||||
<div className={styles.statLabel}>Deine Rolle</div>
|
||||
<div className={styles.statValueSmall}>
|
||||
{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>
|
||||
|
|
|
|||
|
|
@ -2,154 +2,129 @@
|
|||
* TrusteeDocumentsView
|
||||
*
|
||||
* Dokument-Verwaltung für eine Trustee-Instanz.
|
||||
* Zeigt Belege und Dokumente mit Vertragszuordnung.
|
||||
* Verwendet FormGeneratorTable für konsistentes UI.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useTrusteeDocuments, useTrusteeDocumentOperations, TrusteeDocument } from '../../../hooks/useTrustee';
|
||||
import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions';
|
||||
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { Popup } from '../../../components/UiComponents/Popup/Popup';
|
||||
import { TrusteeEditForm, FieldConfig } from './components';
|
||||
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaSync, FaFileAlt, FaDownload } from 'react-icons/fa';
|
||||
import api from '../../../api';
|
||||
import styles from './TrusteeViews.module.css';
|
||||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
export const TrusteeDocumentsView: React.FC = () => {
|
||||
const instanceId = useInstanceId();
|
||||
const { items: documents, loading, error, refetch } = useTrusteeDocuments();
|
||||
const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteeDocumentOperations();
|
||||
const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteeDocument');
|
||||
|
||||
// Options für Label-Auflösung
|
||||
const { getLabelFast, loading: optionsLoading, loadContractsForOrganisation } = useTrusteeOptions(['organisations', 'contracts']);
|
||||
|
||||
// Modal State
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingDoc, setEditingDoc] = useState<TrusteeDocument | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [downloading, setDownloading] = useState<string | null>(null);
|
||||
const [contractOptions, setContractOptions] = useState<Array<{value: string, label: string}>>([]);
|
||||
|
||||
// MIME-Type Options
|
||||
const mimeTypeOptions = [
|
||||
{ value: 'application/pdf', label: 'PDF' },
|
||||
{ value: 'image/jpeg', label: 'JPEG' },
|
||||
{ value: 'image/png', label: 'PNG' },
|
||||
{ value: 'application/octet-stream', label: 'Andere' },
|
||||
];
|
||||
|
||||
// Feld-Konfiguration für das Formular
|
||||
const fields: FieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'organisationId',
|
||||
label: 'Organisation',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
optionsReference: 'organisations',
|
||||
},
|
||||
{
|
||||
key: 'contractId',
|
||||
label: 'Vertrag',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
options: contractOptions,
|
||||
dependsOn: 'organisationId',
|
||||
},
|
||||
{
|
||||
key: 'documentName',
|
||||
label: 'Dokumentname',
|
||||
type: 'string',
|
||||
required: true,
|
||||
placeholder: 'z.B. Rechnung_2026.pdf',
|
||||
},
|
||||
{
|
||||
key: 'documentMimeType',
|
||||
label: 'Dateityp',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
options: mimeTypeOptions,
|
||||
},
|
||||
], [contractOptions]);
|
||||
|
||||
if (loading || optionsLoading) {
|
||||
return <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) {
|
||||
// Entity hook
|
||||
const {
|
||||
items: documents,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
fetchById,
|
||||
updateOptimistically,
|
||||
removeOptimistically,
|
||||
} = useTrusteeDocuments();
|
||||
|
||||
// Operations hook
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
deletingItems,
|
||||
} = useTrusteeDocumentOperations();
|
||||
|
||||
// Modal state
|
||||
const [editingDocument, setEditingDocument] = useState<TrusteeDocument | null>(null);
|
||||
const [isCreateMode, setIsCreateMode] = useState(false);
|
||||
const [downloadingId, setDownloadingId] = useState<string | null>(null);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
if (instanceId) {
|
||||
refetch();
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Generate columns from attributes
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
}));
|
||||
}, [attributes]);
|
||||
|
||||
// Check permissions
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
const canUpdate = permissions?.update !== 'n';
|
||||
const canDelete = permissions?.delete !== 'n';
|
||||
|
||||
// Handle edit click
|
||||
const handleEditClick = async (doc: TrusteeDocument) => {
|
||||
const fullDoc = await fetchById(doc.id);
|
||||
if (fullDoc) {
|
||||
setEditingDocument(fullDoc);
|
||||
setIsCreateMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle create click
|
||||
const handleCreateClick = () => {
|
||||
setEditingDocument(null);
|
||||
setIsCreateMode(true);
|
||||
};
|
||||
|
||||
// Handle form submit
|
||||
const handleFormSubmit = async (data: Partial<TrusteeDocument>) => {
|
||||
if (isCreateMode) {
|
||||
const result = await handleCreate(data);
|
||||
if (result.success) {
|
||||
setIsCreateMode(false);
|
||||
refetch();
|
||||
}
|
||||
} else if (editingDocument) {
|
||||
const result = await handleUpdate(editingDocument.id, data);
|
||||
if (result.success) {
|
||||
setEditingDocument(null);
|
||||
refetch();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onEdit = async (doc: TrusteeDocument) => {
|
||||
setEditingDoc(doc);
|
||||
setFormError(null);
|
||||
// Lade Contracts für die Organisation
|
||||
if (doc.organisationId) {
|
||||
const contracts = await loadContractsForOrganisation(doc.organisationId);
|
||||
setContractOptions(contracts);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const onCreate = () => {
|
||||
setEditingDoc(null);
|
||||
setFormError(null);
|
||||
setContractOptions([]);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const onCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingDoc(null);
|
||||
setFormError(null);
|
||||
setContractOptions([]);
|
||||
};
|
||||
|
||||
const onSave = async (data: Partial<TrusteeDocument>) => {
|
||||
setFormError(null);
|
||||
|
||||
try {
|
||||
if (editingDoc) {
|
||||
const result = await handleUpdate(editingDoc.id, data);
|
||||
if (!result.success) {
|
||||
setFormError(result.error || 'Fehler beim Aktualisieren');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const result = await handleCreate(data);
|
||||
if (!result.success) {
|
||||
setFormError(result.error || 'Fehler beim Erstellen');
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle delete
|
||||
const handleDeleteDoc = async (doc: TrusteeDocument) => {
|
||||
if (window.confirm(`Dokument "${doc.documentName}" wirklich löschen?`)) {
|
||||
removeOptimistically(doc.id);
|
||||
const success = await handleDelete(doc.id);
|
||||
if (!success) {
|
||||
refetch(); // Revert on error
|
||||
}
|
||||
|
||||
onCloseModal();
|
||||
refetch();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Ein Fehler ist aufgetreten');
|
||||
}
|
||||
};
|
||||
|
||||
const onDownload = async (doc: TrusteeDocument) => {
|
||||
|
||||
// Handle download
|
||||
const handleDownload = async (doc: TrusteeDocument) => {
|
||||
if (!instanceId) return;
|
||||
|
||||
setDownloading(doc.id);
|
||||
setDownloadingId(doc.id);
|
||||
try {
|
||||
const response = await api.get(
|
||||
`/api/trustee/${instanceId}/documents/${doc.id}/data`,
|
||||
{ responseType: 'blob' }
|
||||
);
|
||||
|
||||
// Blob-Download
|
||||
const blob = new Blob([response.data], { type: doc.documentMimeType });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
|
@ -163,112 +138,174 @@ export const TrusteeDocumentsView: React.FC = () => {
|
|||
console.error('Download error:', err);
|
||||
alert('Fehler beim Herunterladen des Dokuments.');
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
setDownloadingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// MIME-Type zu lesbarem Text
|
||||
const getMimeTypeLabel = (mimeType: string) => {
|
||||
const found = mimeTypeOptions.find(o => o.value === mimeType);
|
||||
return found?.label || mimeType?.split('/')[1]?.toUpperCase() || 'Unbekannt';
|
||||
|
||||
// Close modal
|
||||
const handleCloseModal = () => {
|
||||
setEditingDocument(null);
|
||||
setIsCreateMode(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.listView}>
|
||||
{/* Toolbar */}
|
||||
<div className={styles.toolbar}>
|
||||
{canCreate && (
|
||||
<button className={styles.primaryButton} onClick={onCreate}>
|
||||
+ Neues Dokument
|
||||
|
||||
// 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 (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler beim Laden der Dokumente: {error}</p>
|
||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
||||
<FaSync /> Erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabelle */}
|
||||
{documents.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<p>Keine Dokumente vorhanden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className={styles.dataTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Typ</th>
|
||||
<th>Vertrag</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{documents.map((doc) => (
|
||||
<tr key={doc.id}>
|
||||
<td>{doc.documentName}</td>
|
||||
<td>
|
||||
<span className={styles.badge}>
|
||||
{getMimeTypeLabel(doc.documentMimeType)}
|
||||
</span>
|
||||
</td>
|
||||
<td>{getLabelFast('contracts', doc.contractId)}</td>
|
||||
<td className={styles.actions}>
|
||||
<button
|
||||
className={styles.iconButton}
|
||||
title="Herunterladen"
|
||||
onClick={() => onDownload(doc)}
|
||||
disabled={downloading === doc.id}
|
||||
>
|
||||
{downloading === doc.id ? '...' : '⬇️'}
|
||||
</button>
|
||||
{canUpdate && (
|
||||
<button
|
||||
className={styles.iconButton}
|
||||
title="Bearbeiten"
|
||||
onClick={() => onEdit(doc)}
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<button
|
||||
className={styles.iconButton}
|
||||
title="Löschen"
|
||||
onClick={() => onDelete(doc.id)}
|
||||
disabled={deletingItems.has(doc.id)}
|
||||
>
|
||||
{deletingItems.has(doc.id) ? '...' : '🗑️'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Popup
|
||||
isOpen={isModalOpen}
|
||||
title={editingDoc ? 'Dokument bearbeiten' : 'Neues Dokument'}
|
||||
onClose={onCloseModal}
|
||||
size="medium"
|
||||
>
|
||||
{formError && (
|
||||
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
|
||||
{formError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 && (
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleCreateClick}
|
||||
>
|
||||
+ Neues Dokument
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableContainer}>
|
||||
{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}>
|
||||
<FaFileAlt className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Dokumente vorhanden</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Erstellen Sie ein neues Dokument, um zu beginnen.
|
||||
</p>
|
||||
{canCreate && (
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleCreateClick}
|
||||
>
|
||||
+ Neues Dokument
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorTable
|
||||
data={documents}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
actionButtons={[
|
||||
...(canUpdate ? [{
|
||||
type: 'edit' as const,
|
||||
onAction: handleEditClick,
|
||||
title: 'Bearbeiten',
|
||||
}] : []),
|
||||
...(canDelete ? [{
|
||||
type: 'delete' as const,
|
||||
title: 'Löschen',
|
||||
loading: (row: TrusteeDocument) => deletingItems.has(row.id),
|
||||
}] : []),
|
||||
]}
|
||||
customActions={[
|
||||
{
|
||||
id: 'download',
|
||||
icon: <FaDownload />,
|
||||
onClick: handleDownload,
|
||||
title: 'Herunterladen',
|
||||
loading: (row: TrusteeDocument) => downloadingId === row.id,
|
||||
},
|
||||
]}
|
||||
onDelete={handleDeleteDoc}
|
||||
hookData={{
|
||||
refetch,
|
||||
permissions,
|
||||
pagination,
|
||||
handleDelete,
|
||||
handleInlineUpdate,
|
||||
updateOptimistically,
|
||||
}}
|
||||
emptyMessage="Keine Dokumente gefunden"
|
||||
/>
|
||||
)}
|
||||
<TrusteeEditForm<TrusteeDocument>
|
||||
initialData={editingDoc || { documentMimeType: 'application/pdf' }}
|
||||
fields={fields}
|
||||
onSave={onSave}
|
||||
onCancel={onCloseModal}
|
||||
isSaving={creatingItem}
|
||||
isEdit={!!editingDoc}
|
||||
saveLabel={editingDoc ? 'Aktualisieren' : 'Erstellen'}
|
||||
/>
|
||||
</Popup>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{(editingDocument || isCreateMode) && (
|
||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>
|
||||
{isCreateMode ? 'Neues Dokument' : 'Dokument bearbeiten'}
|
||||
</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
data={editingDocument || {}}
|
||||
mode={isCreateMode ? 'create' : 'edit'}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={handleCloseModal}
|
||||
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
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
|
||||
*
|
||||
* Verknüpfungs-Verwaltung zwischen Positionen und Dokumenten.
|
||||
* Ermöglicht das Zuweisen von Belegen zu Buchungspositionen.
|
||||
* Verwendet FormGeneratorTable für konsistentes UI.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useTrusteePositionDocuments, useTrusteePositionDocumentOperations, TrusteePositionDocument } from '../../../hooks/useTrustee';
|
||||
import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions';
|
||||
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
||||
import { Popup } from '../../../components/UiComponents/Popup/Popup';
|
||||
import { TrusteeEditForm, FieldConfig } from './components';
|
||||
import styles from './TrusteeViews.module.css';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaSync, FaLink } from 'react-icons/fa';
|
||||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
export const TrusteePositionDocumentsView: React.FC = () => {
|
||||
const { items: links, loading, error, refetch } = useTrusteePositionDocuments();
|
||||
const { handleDelete, handleCreate, deletingItems, creatingItem } = useTrusteePositionDocumentOperations();
|
||||
const { canCreate, canDelete } = useTablePermission('TrusteePositionDocument');
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
// Options für Label-Auflösung
|
||||
const { getLabelFast, loading: optionsLoading, loadContractsForOrganisation } = useTrusteeOptions(['organisations', 'contracts', 'positions', 'documents']);
|
||||
|
||||
// Modal State
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [contractOptions, setContractOptions] = useState<Array<{value: string, label: string}>>([]);
|
||||
|
||||
// Feld-Konfiguration für das Formular
|
||||
const fields: FieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'organisationId',
|
||||
label: 'Organisation',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
optionsReference: 'organisations',
|
||||
},
|
||||
{
|
||||
key: 'contractId',
|
||||
label: 'Vertrag',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
options: contractOptions,
|
||||
dependsOn: 'organisationId',
|
||||
},
|
||||
{
|
||||
key: 'positionId',
|
||||
label: 'Position',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
optionsReference: 'positions',
|
||||
helpText: 'Die Buchungsposition, der ein Beleg zugewiesen werden soll',
|
||||
},
|
||||
{
|
||||
key: 'documentId',
|
||||
label: 'Dokument',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
optionsReference: 'documents',
|
||||
helpText: 'Der Beleg, der der Position zugewiesen werden soll',
|
||||
},
|
||||
], [contractOptions]);
|
||||
|
||||
if (loading || optionsLoading) {
|
||||
return <div className={styles.loading}>Lade Verknüpfungen...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className={styles.error}>Fehler: {error}</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();
|
||||
// Entity hook
|
||||
const {
|
||||
items: links,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
removeOptimistically,
|
||||
} = useTrusteePositionDocuments();
|
||||
|
||||
// Operations hook
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
} = useTrusteePositionDocumentOperations();
|
||||
|
||||
// Modal state
|
||||
const [isCreateMode, setIsCreateMode] = useState(false);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
if (instanceId) {
|
||||
refetch();
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Generate columns from attributes
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
}));
|
||||
}, [attributes]);
|
||||
|
||||
// Check permissions
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
const canDelete = permissions?.delete !== 'n';
|
||||
|
||||
// Handle create click
|
||||
const handleCreateClick = () => {
|
||||
setIsCreateMode(true);
|
||||
};
|
||||
|
||||
// Handle form submit
|
||||
const handleFormSubmit = async (data: Partial<TrusteePositionDocument>) => {
|
||||
const result = await handleCreate(data);
|
||||
if (result.success) {
|
||||
setIsCreateMode(false);
|
||||
refetch();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Ein Fehler ist aufgetreten');
|
||||
}
|
||||
};
|
||||
|
||||
// Gruppiere nach Position für bessere Übersicht
|
||||
const groupedByPosition = useMemo(() => {
|
||||
const grouped: Record<string, TrusteePositionDocument[]> = {};
|
||||
links.forEach(link => {
|
||||
if (!grouped[link.positionId]) {
|
||||
grouped[link.positionId] = [];
|
||||
|
||||
// Handle delete
|
||||
const handleDeleteLink = async (link: TrusteePositionDocument) => {
|
||||
if (window.confirm('Verknüpfung wirklich entfernen?')) {
|
||||
removeOptimistically(link.id);
|
||||
const success = await handleDelete(link.id);
|
||||
if (!success) {
|
||||
refetch(); // Revert on error
|
||||
}
|
||||
grouped[link.positionId].push(link);
|
||||
});
|
||||
return grouped;
|
||||
}, [links]);
|
||||
|
||||
return (
|
||||
<div className={styles.listView}>
|
||||
{/* Toolbar */}
|
||||
<div className={styles.toolbar}>
|
||||
{canCreate && (
|
||||
<button className={styles.primaryButton} onClick={onCreate}>
|
||||
+ Neue Verknüpfung
|
||||
}
|
||||
};
|
||||
|
||||
// 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) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler beim Laden der Verknüpfungen: {error}</p>
|
||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
||||
<FaSync /> Erneut versuchen
|
||||
</button>
|
||||
)}
|
||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className={styles.muted} style={{ fontSize: '0.8125rem', padding: '0.5rem 0' }}>
|
||||
Hier verknüpfen Sie Belege (Dokumente) mit Buchungspositionen.
|
||||
</div>
|
||||
|
||||
{/* Tabelle */}
|
||||
{links.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<p>Keine Verknüpfungen vorhanden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className={styles.dataTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Position</th>
|
||||
<th>Dokument</th>
|
||||
<th>Vertrag</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{links.map((link) => (
|
||||
<tr key={link.id}>
|
||||
<td>{getLabelFast('positions', link.positionId)}</td>
|
||||
<td>{getLabelFast('documents', link.documentId)}</td>
|
||||
<td>{getLabelFast('contracts', link.contractId)}</td>
|
||||
<td className={styles.actions}>
|
||||
{canDelete && (
|
||||
<button
|
||||
className={styles.iconButton}
|
||||
title="Verknüpfung entfernen"
|
||||
onClick={() => onDelete(link.id)}
|
||||
disabled={deletingItems.has(link.id)}
|
||||
>
|
||||
{deletingItems.has(link.id) ? '...' : '🗑️'}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Popup
|
||||
isOpen={isModalOpen}
|
||||
title="Neue Verknüpfung erstellen"
|
||||
onClose={onCloseModal}
|
||||
size="medium"
|
||||
>
|
||||
{formError && (
|
||||
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
|
||||
{formError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.pageHeader}>
|
||||
<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 && (
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleCreateClick}
|
||||
>
|
||||
+ Neue Verknüpfung
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableContainer}>
|
||||
{loading && (!links || links.length === 0) ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Verknüpfungen...</span>
|
||||
</div>
|
||||
) : !links || links.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaLink className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Verknüpfungen vorhanden</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Verknüpfen Sie Belege mit Buchungspositionen.
|
||||
</p>
|
||||
{canCreate && (
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleCreateClick}
|
||||
>
|
||||
+ Neue Verknüpfung
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorTable
|
||||
data={links}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
actionButtons={[
|
||||
...(canDelete ? [{
|
||||
type: 'delete' as const,
|
||||
title: 'Verknüpfung entfernen',
|
||||
loading: (row: TrusteePositionDocument) => deletingItems.has(row.id),
|
||||
}] : []),
|
||||
]}
|
||||
onDelete={handleDeleteLink}
|
||||
hookData={{
|
||||
refetch,
|
||||
permissions,
|
||||
pagination,
|
||||
handleDelete,
|
||||
}}
|
||||
emptyMessage="Keine Verknüpfungen gefunden"
|
||||
/>
|
||||
)}
|
||||
<TrusteeEditForm<TrusteePositionDocument>
|
||||
initialData={{}}
|
||||
fields={fields}
|
||||
onSave={onSave}
|
||||
onCancel={onCloseModal}
|
||||
isSaving={creatingItem}
|
||||
isEdit={false}
|
||||
saveLabel="Verknüpfung erstellen"
|
||||
/>
|
||||
</Popup>
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{isCreateMode && (
|
||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>Neue Verknüpfung erstellen</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
data={{}}
|
||||
mode="create"
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={handleCloseModal}
|
||||
submitButtonText="Verknüpfung erstellen"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,322 +2,285 @@
|
|||
* TrusteePositionsView
|
||||
*
|
||||
* Positions-Verwaltung für eine Trustee-Instanz.
|
||||
* Zeigt Buchungspositionen (Speseneinträge) mit Beträgen.
|
||||
* Verwendet FormGeneratorTable für konsistentes UI.
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo, useCallback } from 'react';
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { useTrusteePositions, useTrusteePositionOperations, TrusteePosition } from '../../../hooks/useTrustee';
|
||||
import { useTrusteeOptions } from '../../../hooks/useTrusteeOptions';
|
||||
import { useTablePermission } from '../../../hooks/useInstancePermissions';
|
||||
import { Popup } from '../../../components/UiComponents/Popup/Popup';
|
||||
import { TrusteeEditForm, FieldConfig } from './components';
|
||||
import styles from './TrusteeViews.module.css';
|
||||
import { useInstanceId } from '../../../hooks/useCurrentInstance';
|
||||
import { FormGeneratorTable } from '../../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaSync, FaReceipt } from 'react-icons/fa';
|
||||
import styles from '../../admin/Admin.module.css';
|
||||
|
||||
export const TrusteePositionsView: React.FC = () => {
|
||||
const { items: positions, loading, error, refetch } = useTrusteePositions();
|
||||
const { handleDelete, handleCreate, handleUpdate, deletingItems, creatingItem } = useTrusteePositionOperations();
|
||||
const { canCreate, canUpdate, canDelete } = useTablePermission('TrusteePosition');
|
||||
const instanceId = useInstanceId();
|
||||
|
||||
// Options für Label-Auflösung
|
||||
const { getLabelFast, loading: optionsLoading, loadContractsForOrganisation } = useTrusteeOptions(['organisations', 'contracts']);
|
||||
|
||||
// Modal State
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
// Entity hook
|
||||
const {
|
||||
items: positions,
|
||||
attributes,
|
||||
permissions,
|
||||
pagination,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
fetchById,
|
||||
updateOptimistically,
|
||||
removeOptimistically,
|
||||
} = useTrusteePositions();
|
||||
|
||||
// Operations hook
|
||||
const {
|
||||
handleDelete,
|
||||
handleCreate,
|
||||
handleUpdate,
|
||||
deletingItems,
|
||||
creatingItem,
|
||||
} = useTrusteePositionOperations();
|
||||
|
||||
// Modal state
|
||||
const [editingPosition, setEditingPosition] = useState<TrusteePosition | null>(null);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
const [contractOptions, setContractOptions] = useState<Array<{value: string, label: string}>>([]);
|
||||
|
||||
// Währungs-Options
|
||||
const currencyOptions = [
|
||||
{ value: 'CHF', label: 'CHF' },
|
||||
{ value: 'EUR', label: 'EUR' },
|
||||
{ value: 'USD', label: 'USD' },
|
||||
{ value: 'GBP', label: 'GBP' },
|
||||
];
|
||||
|
||||
// Feld-Konfiguration für das Formular
|
||||
const fields: FieldConfig[] = useMemo(() => [
|
||||
{
|
||||
key: 'organisationId',
|
||||
label: 'Organisation',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
optionsReference: 'organisations',
|
||||
},
|
||||
{
|
||||
key: 'contractId',
|
||||
label: 'Vertrag',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
options: contractOptions,
|
||||
dependsOn: 'organisationId',
|
||||
},
|
||||
{
|
||||
key: 'valuta',
|
||||
label: 'Valutadatum',
|
||||
type: 'date',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'company',
|
||||
label: 'Firma',
|
||||
type: 'string',
|
||||
placeholder: 'Name des Lieferanten/Empfängers',
|
||||
},
|
||||
{
|
||||
key: 'desc',
|
||||
label: 'Beschreibung',
|
||||
type: 'textarea',
|
||||
placeholder: 'Beschreibung der Position',
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
label: 'Tags',
|
||||
type: 'string',
|
||||
placeholder: 'Komma-getrennte Tags',
|
||||
helpText: 'z.B. Reise, Spesen, IT',
|
||||
},
|
||||
{
|
||||
key: 'bookingCurrency',
|
||||
label: 'Buchungswährung',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
options: currencyOptions,
|
||||
},
|
||||
{
|
||||
key: 'bookingAmount',
|
||||
label: 'Buchungsbetrag',
|
||||
type: 'number',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: 'originalCurrency',
|
||||
label: 'Originalwährung',
|
||||
type: 'enum',
|
||||
required: true,
|
||||
options: currencyOptions,
|
||||
},
|
||||
{
|
||||
key: 'originalAmount',
|
||||
label: 'Originalbetrag',
|
||||
type: 'number',
|
||||
required: true,
|
||||
helpText: 'Betrag in Originalwährung (keine automatische Umrechnung)',
|
||||
},
|
||||
{
|
||||
key: 'vatPercentage',
|
||||
label: 'MwSt %',
|
||||
type: 'number',
|
||||
helpText: 'MwSt-Satz in Prozent (z.B. 8.1)',
|
||||
},
|
||||
{
|
||||
key: 'vatAmount',
|
||||
label: 'MwSt Betrag',
|
||||
type: 'number',
|
||||
helpText: 'Wird automatisch berechnet (kann manuell überschrieben werden)',
|
||||
},
|
||||
], [contractOptions]);
|
||||
|
||||
if (loading || optionsLoading) {
|
||||
return <div className={styles.loading}>Lade Positionen...</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <div className={styles.error}>Fehler: {error}</div>;
|
||||
}
|
||||
|
||||
const onDelete = async (posId: string) => {
|
||||
if (window.confirm('Position wirklich löschen?')) {
|
||||
const success = await handleDelete(posId);
|
||||
if (success) {
|
||||
refetch();
|
||||
}
|
||||
const [isCreateMode, setIsCreateMode] = useState(false);
|
||||
|
||||
// Initial fetch
|
||||
useEffect(() => {
|
||||
if (instanceId) {
|
||||
refetch();
|
||||
}
|
||||
}, [instanceId]);
|
||||
|
||||
// Generate columns from attributes
|
||||
const columns = useMemo(() => {
|
||||
return (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
sortable: attr.sortable !== false,
|
||||
filterable: attr.filterable !== false,
|
||||
searchable: attr.searchable !== false,
|
||||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
}));
|
||||
}, [attributes]);
|
||||
|
||||
// Check permissions
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
const canUpdate = permissions?.update !== 'n';
|
||||
const canDelete = permissions?.delete !== 'n';
|
||||
|
||||
// Handle edit click
|
||||
const handleEditClick = async (pos: TrusteePosition) => {
|
||||
const fullPos = await fetchById(pos.id);
|
||||
if (fullPos) {
|
||||
setEditingPosition(fullPos);
|
||||
setIsCreateMode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onEdit = async (pos: TrusteePosition) => {
|
||||
setEditingPosition(pos);
|
||||
setFormError(null);
|
||||
// Lade Contracts für die Organisation
|
||||
if (pos.organisationId) {
|
||||
const contracts = await loadContractsForOrganisation(pos.organisationId);
|
||||
setContractOptions(contracts);
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const onCreate = () => {
|
||||
|
||||
// Handle create click
|
||||
const handleCreateClick = () => {
|
||||
setEditingPosition(null);
|
||||
setFormError(null);
|
||||
setContractOptions([]);
|
||||
setIsModalOpen(true);
|
||||
setIsCreateMode(true);
|
||||
};
|
||||
|
||||
const onCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingPosition(null);
|
||||
setFormError(null);
|
||||
setContractOptions([]);
|
||||
};
|
||||
|
||||
const onSave = async (data: Partial<TrusteePosition>) => {
|
||||
setFormError(null);
|
||||
|
||||
// MwSt automatisch berechnen wenn nicht gesetzt
|
||||
|
||||
// Handle form submit
|
||||
const handleFormSubmit = async (data: Partial<TrusteePosition>) => {
|
||||
// Auto-calculate VAT if provided
|
||||
const processedData = { ...data };
|
||||
if (processedData.bookingAmount && processedData.vatPercentage && !processedData.vatAmount) {
|
||||
processedData.vatAmount = processedData.bookingAmount * (processedData.vatPercentage / 100);
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingPosition) {
|
||||
const result = await handleUpdate(editingPosition.id, processedData);
|
||||
if (!result.success) {
|
||||
setFormError(result.error || 'Fehler beim Aktualisieren');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const result = await handleCreate(processedData);
|
||||
if (!result.success) {
|
||||
setFormError(result.error || 'Fehler beim Erstellen');
|
||||
return;
|
||||
}
|
||||
if (isCreateMode) {
|
||||
const result = await handleCreate(processedData);
|
||||
if (result.success) {
|
||||
setIsCreateMode(false);
|
||||
refetch();
|
||||
}
|
||||
} else if (editingPosition) {
|
||||
const result = await handleUpdate(editingPosition.id, processedData);
|
||||
if (result.success) {
|
||||
setEditingPosition(null);
|
||||
refetch();
|
||||
}
|
||||
|
||||
onCloseModal();
|
||||
refetch();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Ein Fehler ist aufgetreten');
|
||||
}
|
||||
};
|
||||
|
||||
// Formatiere Betrag
|
||||
const formatAmount = (amount: number, currency: string) => {
|
||||
return new Intl.NumberFormat('de-CH', {
|
||||
style: 'currency',
|
||||
currency: currency || 'CHF'
|
||||
}).format(amount);
|
||||
};
|
||||
|
||||
// Formatiere Datum
|
||||
const formatDate = (dateStr: string | null | undefined) => {
|
||||
if (!dateStr) return '-';
|
||||
try {
|
||||
return new Date(dateStr).toLocaleDateString('de-CH');
|
||||
} catch {
|
||||
return dateStr;
|
||||
|
||||
// Handle delete
|
||||
const handleDeletePos = async (pos: TrusteePosition) => {
|
||||
if (window.confirm(`Position "${pos.desc || pos.id}" wirklich löschen?`)) {
|
||||
removeOptimistically(pos.id);
|
||||
const success = await handleDelete(pos.id);
|
||||
if (!success) {
|
||||
refetch(); // Revert on error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.listView}>
|
||||
{/* Toolbar */}
|
||||
<div className={styles.toolbar}>
|
||||
{canCreate && (
|
||||
<button className={styles.primaryButton} onClick={onCreate}>
|
||||
+ Neue Position
|
||||
|
||||
// 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>
|
||||
)}
|
||||
<button className={styles.secondaryButton} onClick={() => refetch()}>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabelle */}
|
||||
{positions.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<p>Keine Positionen vorhanden.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className={styles.dataTable}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Valuta</th>
|
||||
<th>Firma</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Vertrag</th>
|
||||
<th className={styles.alignRight}>Betrag</th>
|
||||
<th className={styles.alignRight}>MwSt</th>
|
||||
<th>Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{positions.map((pos) => (
|
||||
<tr key={pos.id}>
|
||||
<td>{formatDate(pos.valuta)}</td>
|
||||
<td>{pos.company || '-'}</td>
|
||||
<td className={styles.truncate} title={pos.desc}>
|
||||
{pos.desc || '-'}
|
||||
</td>
|
||||
<td>{getLabelFast('contracts', pos.contractId)}</td>
|
||||
<td className={styles.alignRight}>
|
||||
{formatAmount(pos.bookingAmount, pos.bookingCurrency)}
|
||||
</td>
|
||||
<td className={styles.alignRight}>
|
||||
{pos.vatPercentage > 0 ? (
|
||||
<span title={formatAmount(pos.vatAmount, pos.bookingCurrency)}>
|
||||
{pos.vatPercentage}%
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.muted}>-</span>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Popup
|
||||
isOpen={isModalOpen}
|
||||
title={editingPosition ? 'Position bearbeiten' : 'Neue Position'}
|
||||
onClose={onCloseModal}
|
||||
size="large"
|
||||
>
|
||||
{formError && (
|
||||
<div className={styles.formError} style={{ marginBottom: '1rem' }}>
|
||||
{formError}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.pageHeader}>
|
||||
<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 && (
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleCreateClick}
|
||||
>
|
||||
+ Neue Position
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.tableContainer}>
|
||||
{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}>
|
||||
<FaReceipt className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Positionen vorhanden</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Erstellen Sie eine neue Position, um zu beginnen.
|
||||
</p>
|
||||
{canCreate && (
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleCreateClick}
|
||||
>
|
||||
+ Neue Position
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorTable
|
||||
data={positions}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
actionButtons={[
|
||||
...(canUpdate ? [{
|
||||
type: 'edit' as const,
|
||||
onAction: handleEditClick,
|
||||
title: 'Bearbeiten',
|
||||
}] : []),
|
||||
...(canDelete ? [{
|
||||
type: 'delete' as const,
|
||||
title: 'Löschen',
|
||||
loading: (row: TrusteePosition) => deletingItems.has(row.id),
|
||||
}] : []),
|
||||
]}
|
||||
onDelete={handleDeletePos}
|
||||
hookData={{
|
||||
refetch,
|
||||
permissions,
|
||||
pagination,
|
||||
handleDelete,
|
||||
handleInlineUpdate,
|
||||
updateOptimistically,
|
||||
}}
|
||||
emptyMessage="Keine Positionen gefunden"
|
||||
/>
|
||||
)}
|
||||
<TrusteeEditForm<TrusteePosition>
|
||||
initialData={editingPosition || {
|
||||
bookingCurrency: 'CHF',
|
||||
originalCurrency: 'CHF',
|
||||
bookingAmount: 0,
|
||||
originalAmount: 0,
|
||||
vatPercentage: 0,
|
||||
vatAmount: 0,
|
||||
}}
|
||||
fields={fields}
|
||||
onSave={onSave}
|
||||
onCancel={onCloseModal}
|
||||
isSaving={creatingItem}
|
||||
isEdit={!!editingPosition}
|
||||
saveLabel={editingPosition ? 'Aktualisieren' : 'Erstellen'}
|
||||
/>
|
||||
</Popup>
|
||||
</div>
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
{(editingPosition || isCreateMode) && (
|
||||
<div className={styles.modalOverlay} onClick={handleCloseModal}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>
|
||||
{isCreateMode ? 'Neue Position' : 'Position bearbeiten'}
|
||||
</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={handleCloseModal}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
data={editingPosition || {
|
||||
bookingCurrency: 'CHF',
|
||||
originalCurrency: 'CHF',
|
||||
bookingAmount: 0,
|
||||
originalAmount: 0,
|
||||
vatPercentage: 0,
|
||||
vatAmount: 0,
|
||||
}}
|
||||
mode={isCreateMode ? 'create' : 'edit'}
|
||||
onSubmit={handleFormSubmit}
|
||||
onCancel={handleCloseModal}
|
||||
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/* Kompakte Variante für Text-Werte wie Rollen */
|
||||
.statValueSmall {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #666);
|
||||
|
|
@ -298,6 +306,7 @@
|
|||
}
|
||||
|
||||
:global(.dark-theme) .statValue,
|
||||
:global(.dark-theme) .statValueSmall,
|
||||
:global(.dark-theme) .infoSection h3,
|
||||
:global(.dark-theme) .infoValue {
|
||||
color: var(--text-primary-dark, #ffffff);
|
||||
|
|
@ -484,3 +493,142 @@
|
|||
:global(.dark-theme) .formActions {
|
||||
border-top-color: var(--border-dark, #333);
|
||||
}
|
||||
|
||||
/* Instance Roles View */
|
||||
.rolesList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.roleCard {
|
||||
background: var(--bg-primary, #ffffff);
|
||||
border: 1px solid var(--border-color, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.roleHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.roleHeader:hover {
|
||||
background: var(--surface-color, #f8f9fa);
|
||||
}
|
||||
|
||||
.roleInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.expandIcon {
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.roleLabel {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.roleDescription {
|
||||
color: var(--text-secondary, #666);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.roleBadges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.systemBadge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: var(--info-light, #e0f2fe);
|
||||
color: var(--info-color, #0284c7);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.roleContent {
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border-color, #e0e0e0);
|
||||
background: var(--surface-color, #f8f9fa);
|
||||
}
|
||||
|
||||
.infoBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--info-light, #e0f2fe);
|
||||
border: 1px solid var(--info-color, #0284c7);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--info-color, #0284c7);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.emptyIcon {
|
||||
font-size: 3rem;
|
||||
color: var(--text-tertiary, #999);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.emptyHint {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-tertiary, #999);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.retryButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--primary-color, #3b82f6);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.retryButton:hover {
|
||||
background: var(--primary-dark, #2563eb);
|
||||
}
|
||||
|
||||
/* Dark Theme - Instance Roles */
|
||||
:global(.dark-theme) .roleCard {
|
||||
background: var(--surface-dark, #1a1a1a);
|
||||
border-color: var(--border-dark, #333);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .roleHeader:hover {
|
||||
background: var(--surface-dark, #2a2a2a);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .roleLabel {
|
||||
color: var(--text-primary-dark, #ffffff);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .roleDescription {
|
||||
color: var(--text-secondary-dark, #aaa);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .roleContent {
|
||||
background: var(--surface-dark, #2a2a2a);
|
||||
border-top-color: var(--border-dark, #333);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .infoBox {
|
||||
background: var(--info-dark, #0c4a6e);
|
||||
border-color: var(--info-color, #0284c7);
|
||||
color: var(--info-light, #e0f2fe);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
export type { FieldConfig, TrusteeEditFormProps } from './TrusteeEditForm';
|
||||
// Keine lokalen Komponenten mehr - alle Views nutzen FormGeneratorTable/FormGeneratorForm
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
/**
|
||||
* Trustee Views Export
|
||||
*
|
||||
* Note: TrusteeOrganisationsView, TrusteeContractsView, TrusteeRolesView, TrusteeAccessView
|
||||
* wurden entfernt - Feature-Instanz = Organisation
|
||||
*/
|
||||
|
||||
export { TrusteeDashboardView } from './TrusteeDashboardView';
|
||||
export { TrusteeContractsView } from './TrusteeContractsView';
|
||||
export { TrusteeOrganisationsView } from './TrusteeOrganisationsView';
|
||||
export { TrusteeDocumentsView } from './TrusteeDocumentsView';
|
||||
export { TrusteePositionsView } from './TrusteePositionsView';
|
||||
export { TrusteePositionDocumentsView } from './TrusteePositionDocumentsView';
|
||||
export { TrusteeRolesView } from './TrusteeRolesView';
|
||||
export { TrusteeAccessView } from './TrusteeAccessView';
|
||||
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ export interface FeatureInstance {
|
|||
mandateId: string; // Zugehöriger Mandant
|
||||
mandateName: string; // Für Anzeige
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -186,14 +186,13 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
|
|||
code: 'trustee',
|
||||
label: { de: 'Treuhand', en: 'Trustee' },
|
||||
icon: 'briefcase',
|
||||
// Note: Feature-Instanz = Organisation (kein separates Organisations-Objekt)
|
||||
views: [
|
||||
{ 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: 'roles', label: { de: 'Rollen', en: 'Roles' }, path: 'roles' },
|
||||
{ code: 'access', label: { de: 'Zugriffe', en: 'Access' }, path: 'access' },
|
||||
{ code: 'documents', label: { de: 'Dokumente', en: 'Documents' }, path: 'documents' },
|
||||
{ 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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue