This commit is contained in:
ValueOn AG 2026-01-24 18:01:35 +01:00
parent 5952074626
commit 6a406d885d
25 changed files with 1113 additions and 241 deletions

View file

@ -39,7 +39,7 @@ import { FeatureLayout } from './layouts/FeatureLayout';
import { DashboardPage } from './pages/Dashboard';
import { SettingsPage } from './pages/Settings';
import { FeatureViewPage } from './pages/FeatureView';
import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage } from './pages/admin';
import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage } from './pages/admin';
// Workflow Pages (global)
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
@ -168,6 +168,7 @@ function App() {
<Route path="feature-users" element={<AdminFeatureInstanceUsersPage />} />
<Route path="invitations" element={<AdminInvitationsPage />} />
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
</Route>
</Route>

View file

@ -519,10 +519,29 @@ export async function createDocument(
instanceId: string,
data: Partial<TrusteeDocument>
): Promise<TrusteeDocument> {
// If documentData is a File, convert to base64
let processedData = { ...data };
if (data.documentData instanceof File) {
const file = data.documentData as File;
const arrayBuffer = await file.arrayBuffer();
const base64 = btoa(
new Uint8Array(arrayBuffer).reduce((data, byte) => data + String.fromCharCode(byte), '')
);
processedData.documentData = base64 as any;
// Auto-set MIME type from file if not provided
if (!processedData.documentMimeType && file.type) {
processedData.documentMimeType = file.type;
}
// Auto-set name from file if not provided
if (!processedData.documentName && file.name) {
processedData.documentName = file.name;
}
}
return await request({
url: `${_getTrusteeBaseUrl(instanceId)}/documents`,
method: 'post',
data
data: processedData
});
}

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
import api from '../../../api';
import styles from './FormGeneratorForm.module.css';
@ -105,8 +105,19 @@ export function FormGeneratorForm<T extends Record<string, any>>({
}: FormGeneratorFormProps<T>) {
const { t } = useLanguage();
// Helper to resolve API paths with {instanceId} placeholder
const resolveApiPath = (path: string): string => {
const [formData, setFormData] = useState<T>(data || {} as T);
const [errors, setErrors] = useState<Record<string, string>>({});
const [fieldFocused, setFieldFocused] = useState<Record<string, boolean>>({});
const [attributes, setAttributes] = useState<AttributeDefinition[]>(providedAttributes || []);
const [loadingAttributes, setLoadingAttributes] = useState(!providedAttributes);
const [optionsCache, setOptionsCache] = useState<Record<string, Array<{ value: string | number; label: string }>>>({});
const [loadingOptions, setLoadingOptions] = useState<Record<string, boolean>>({});
const [submitting, setSubmitting] = useState(false);
// Track which option keys have been fetched or are being fetched (using ref to avoid re-renders)
const fetchedOrFetchingOptions = useRef<Set<string>>(new Set());
// Helper to resolve API paths with {instanceId} placeholder - memoized to prevent useEffect re-runs
const resolveApiPath = useCallback((path: string): string => {
if (path.includes('{instanceId}')) {
const resolvedInstanceId = instanceId || (data as any)?.featureInstanceId;
if (!resolvedInstanceId) {
@ -116,15 +127,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
return path.replace('{instanceId}', resolvedInstanceId);
}
return path;
};
const [formData, setFormData] = useState<T>(data || {} as T);
const [errors, setErrors] = useState<Record<string, string>>({});
const [fieldFocused, setFieldFocused] = useState<Record<string, boolean>>({});
const [attributes, setAttributes] = useState<AttributeDefinition[]>(providedAttributes || []);
const [loadingAttributes, setLoadingAttributes] = useState(!providedAttributes);
const [optionsCache, setOptionsCache] = useState<Record<string, Array<{ value: string | number; label: string }>>>({});
const [loadingOptions, setLoadingOptions] = useState<Record<string, boolean>>({});
const [submitting, setSubmitting] = useState(false);
}, [instanceId, data]);
// Fetch attributes from backend
useEffect(() => {
@ -252,26 +255,39 @@ export function FormGeneratorForm<T extends Record<string, any>>({
// Fetch options for fields with optionsReference (API path)
// Backend provides options in standardized format: { value, label }
// OPTIMIZED: Only fetch options that are not already fetched or being fetched
useEffect(() => {
const fetchOptions = async () => {
const filteredAttrs = getFilteredAttributes();
const fieldsToFetch = filteredAttrs.filter(attr => {
if (typeof attr.options === 'string' && !optionsCache[attr.options]) {
return true;
// Collect unique option keys that need fetching
const optionKeysToFetch: string[] = [];
filteredAttrs.forEach(attr => {
if (typeof attr.options === 'string') {
const optionKey = attr.options;
// Only fetch if not already fetched or being fetched (using ref for immediate check)
if (!fetchedOrFetchingOptions.current.has(optionKey)) {
optionKeysToFetch.push(optionKey);
// Immediately mark as being fetched to prevent duplicate requests
fetchedOrFetchingOptions.current.add(optionKey);
}
}
return false;
});
if (fieldsToFetch.length === 0) return;
if (optionKeysToFetch.length === 0) return;
for (const field of fieldsToFetch) {
if (typeof field.options !== 'string') continue;
const optionKey = field.options;
setLoadingOptions(prev => ({ ...prev, [field.name]: true }));
// Set loading state for relevant fields
const fieldsLoading: Record<string, boolean> = {};
filteredAttrs.forEach(attr => {
if (typeof attr.options === 'string' && optionKeysToFetch.includes(attr.options)) {
fieldsLoading[attr.name] = true;
}
});
setLoadingOptions(prev => ({ ...prev, ...fieldsLoading }));
// Fetch all options in parallel
const fetchPromises = optionKeysToFetch.map(async (optionKey) => {
try {
// Backend provides full API path (e.g., "/api/connections/statuses/options")
// Resolve {instanceId} placeholder if present
const apiPath = resolveApiPath(optionKey);
const response = await api.get(apiPath);
@ -292,18 +308,37 @@ export function FormGeneratorForm<T extends Record<string, any>>({
});
}
setOptionsCache(prev => ({ ...prev, [optionKey]: fetchedOptions }));
return { key: optionKey, options: fetchedOptions, error: null };
} catch (error: any) {
console.error(`Failed to fetch options for ${field.options}:`, error);
setOptionsCache(prev => ({ ...prev, [field.options as string]: [] }));
} finally {
setLoadingOptions(prev => ({ ...prev, [field.name]: false }));
console.error(`Failed to fetch options for ${optionKey}:`, error);
return { key: optionKey, options: [], error };
}
}
});
// Wait for all fetches to complete
const results = await Promise.all(fetchPromises);
// Update cache with all results at once
setOptionsCache(prev => {
const newCache = { ...prev };
results.forEach(({ key, options }) => {
newCache[key] = options;
});
return newCache;
});
// Clear loading states
const fieldsNotLoading: Record<string, boolean> = {};
filteredAttrs.forEach(attr => {
if (typeof attr.options === 'string' && optionKeysToFetch.includes(attr.options)) {
fieldsNotLoading[attr.name] = false;
}
});
setLoadingOptions(prev => ({ ...prev, ...fieldsNotLoading }));
};
fetchOptions();
}, [getFilteredAttributes, optionsCache, resolveApiPath]);
}, [getFilteredAttributes, resolveApiPath]);
// Handle field focus
const handleFieldFocus = (fieldName: string, focused: boolean) => {
@ -314,10 +349,22 @@ export function FormGeneratorForm<T extends Record<string, any>>({
};
// Handle field value changes
const handleFieldChange = (fieldName: string, value: any) => {
// For timestamp fields: Convert datetime-local string to Unix timestamp (float in seconds)
const handleFieldChange = (fieldName: string, value: any, fieldType?: AttributeType) => {
let processedValue = value;
// If field type is timestamp, convert datetime-local string to Unix timestamp
if (fieldType === 'timestamp' && typeof value === 'string' && value) {
const date = new Date(value);
if (!isNaN(date.getTime())) {
// Convert to Unix timestamp in seconds (float)
processedValue = date.getTime() / 1000;
}
}
setFormData(prev => ({
...prev,
[fieldName]: value
[fieldName]: processedValue
}));
// Clear error for this field when user starts typing
@ -329,6 +376,24 @@ export function FormGeneratorForm<T extends Record<string, any>>({
});
}
};
// Convert Unix timestamp (seconds) to datetime-local input format
const timestampToDatetimeLocal = (timestamp: number | string | null | undefined): string => {
if (timestamp === null || timestamp === undefined || timestamp === '') {
return '';
}
const numValue = typeof timestamp === 'string' ? parseFloat(timestamp) : timestamp;
if (isNaN(numValue)) {
return '';
}
// Unix timestamp in seconds - convert to milliseconds for Date
const date = new Date(numValue * 1000);
if (isNaN(date.getTime())) {
return '';
}
// Format as datetime-local: YYYY-MM-DDTHH:mm
return date.toISOString().slice(0, 16);
};
// Normalize options for a field
const normalizeOptions = (attr: AttributeDefinition): Array<{ value: string | number; label: string }> => {
@ -430,8 +495,17 @@ export function FormGeneratorForm<T extends Record<string, any>>({
// Timestamp/Date validation
if (isDateTimeType(attr.type)) {
const dateValue = new Date(String(value));
if (isNaN(dateValue.getTime())) {
// For timestamp fields, value is stored as Unix timestamp (float)
// For date/time fields, value is stored as string
let isValid = false;
if (attr.type === 'timestamp' && typeof value === 'number') {
// Unix timestamp in seconds - valid if it's a reasonable timestamp
isValid = value > 0 && value < 4102444800; // Before year 2100
} else {
const dateValue = new Date(String(value));
isValid = !isNaN(dateValue.getTime());
}
if (!isValid) {
newErrors[attr.name] = t('formgen.form.invalidDate', 'Invalid date format');
return;
}
@ -836,26 +910,32 @@ export function FormGeneratorForm<T extends Record<string, any>>({
);
}
// Default input field (text, email, date, time, url, password, number, integer, float)
// Default input field (text, email, date, time, url, password, number, integer, float, timestamp)
const inputType = attributeTypeToInputType(attr.type);
// For timestamp fields, convert Unix timestamp (float) to datetime-local format for display
const displayValue = attr.type === 'timestamp'
? timestampToDatetimeLocal(value)
: (value || '');
return (
<div className={styles.floatingLabelInput} key={attr.name}>
<input
type={inputType}
value={value || ''}
value={displayValue}
onChange={(e) => {
let newValue: any = e.target.value;
if (isNumberType(attr.type)) {
newValue = e.target.value === '' ? '' : Number(e.target.value);
}
handleFieldChange(attr.name, newValue);
// Pass field type for timestamp conversion
handleFieldChange(attr.name, newValue, attr.type);
}}
onFocus={() => handleFieldFocus(attr.name, true)}
onBlur={() => handleFieldFocus(attr.name, false)}
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
/>
<label className={getLabelClass(attr.name, value)}>
<label className={getLabelClass(attr.name, displayValue)}>
{attr.label}
{attr.required && <span className={styles.required}>*</span>}
</label>

View file

@ -111,72 +111,32 @@ export function FormGeneratorList<T extends Record<string, any>>({
}: FormGeneratorListProps<T>) {
const { t } = useLanguage();
// Cache fields so they persist even when data is empty
// Cache fields so they persist even when data is empty (e.g., after filtering)
// NO AUTO-DETECTION - fields must come from backend attribute definitions
const fieldsRef = useRef<FieldConfig[]>([]);
const detectedFields = useMemo((): FieldConfig[] => {
// Always use providedFields if available
// Use providedFields from Pydantic attribute definitions
if (providedFields && providedFields.length > 0) {
fieldsRef.current = providedFields;
return providedFields;
}
// If we have cached fields and no new fields provided, use cached fields
if (fieldsRef.current.length > 0 && data.length === 0) {
// Use cached fields if data becomes empty (e.g., after filtering)
if (fieldsRef.current.length > 0) {
return fieldsRef.current;
}
// Only auto-detect if no fields provided AND we have data
if (data.length === 0) {
return fieldsRef.current;
}
// NO FIELDS PROVIDED - this is an error in the calling component
// The calling component should provide fields from the /attributes/{entityType} endpoint
console.warn(
'⚠️ FormGeneratorList: No fields provided! ' +
'Fields should come from Pydantic attribute definitions via /attributes/{entityType} endpoint. ' +
'Please ensure the calling component fetches and passes fields from the backend.'
);
const sampleRow = data[0];
const autoDetected = Object.keys(sampleRow).map(key => {
const value = sampleRow[key];
let type: FieldConfig['type'] = 'string';
// Check if field name suggests it's a timestamp/date field
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked)$/i.test(key);
// Auto-detect type based on value
if (typeof value === 'number') {
if (isTimestampField || (value > 0 && value < 4102444800000)) {
if (value < 10000000000) {
type = 'date';
} else if (value < 4102444800000) {
type = 'date';
} else {
type = 'number';
}
} else {
type = 'number';
}
} else if (typeof value === 'boolean') {
type = 'boolean';
} else if (value instanceof Date || (typeof value === 'string' && !isNaN(Date.parse(value)) && value.includes('-'))) {
type = 'date';
} else if (isTimestampField && typeof value === 'string') {
const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue > 0 && numValue < 4102444800000) {
type = 'date';
}
}
return {
key,
label: key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1'),
type,
editable: false
};
});
// Cache auto-detected fields
if (autoDetected.length > 0) {
fieldsRef.current = autoDetected;
}
return autoDetected;
// Return empty array - list will show no fields
return [];
}, [providedFields, data]);
// State management

View file

@ -409,6 +409,7 @@ tbody .actionsColumn {
.actionButtons {
display: flex;
flex-wrap: nowrap;
gap: 2px;
justify-content: center;
align-items: center;
@ -416,6 +417,11 @@ tbody .actionsColumn {
margin: 0 auto;
}
/* Enable wrapping only when column exceeds 20% of container width */
.actionButtonsWrap {
flex-wrap: wrap;
}
.actionButton {
display: flex;
align-items: center;

View file

@ -202,90 +202,33 @@ export function FormGeneratorTable<T extends Record<string, any>>({
}
return 'en'; // Default to English
}, []);
// Use provided columns (from attributes) if available, otherwise auto-detect from data
// Columns should persist even when data is empty (e.g., after filtering)
// Use a ref to cache columns so they persist across data changes
// Use provided columns from Pydantic attribute definitions
// NO AUTO-DETECTION - columns must come from backend attribute definitions
// Use a ref to cache columns so they persist across data changes (e.g., when filtering)
const columnsRef = useRef<ColumnConfig[]>([]);
const detectedColumns = useMemo((): ColumnConfig[] => {
// Always use providedColumns if available (from attributes/hookData.columns)
// This ensures columns persist even when data is empty
// Use providedColumns from Pydantic attribute definitions
if (providedColumns && providedColumns.length > 0) {
columnsRef.current = providedColumns;
return providedColumns;
}
// If we have cached columns and no new columns provided, use cached columns
// This prevents columns from disappearing when data becomes empty
if (columnsRef.current.length > 0 && data.length === 0) {
// Use cached columns if data becomes empty (e.g., after filtering)
if (columnsRef.current.length > 0) {
return columnsRef.current;
}
// Only auto-detect if no columns provided AND we have data
if (data.length === 0) {
// Return cached columns if available, otherwise empty array
return columnsRef.current;
}
// NO COLUMNS PROVIDED - this is an error in the calling component
// The calling component should provide columns from the /attributes/{entityType} endpoint
console.warn(
'⚠️ FormGeneratorTable: No columns provided! ' +
'Columns should come from Pydantic attribute definitions via /attributes/{entityType} endpoint. ' +
'Please ensure the calling component fetches and passes columns from the backend.'
);
const sampleRow = data[0];
const autoDetected = Object.keys(sampleRow).map(key => {
const value = sampleRow[key];
let type: ColumnConfig['type'] = 'string';
// Check if field name suggests it's a timestamp/date field
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked)$/i.test(key);
// Auto-detect type based on value
if (typeof value === 'number') {
// Check if it's a Unix timestamp (in seconds or milliseconds)
// Unix timestamps are typically between 1970-01-01 (0) and year 2100 (4102444800 in seconds, 4102444800000 in ms)
if (isTimestampField || (value > 0 && value < 4102444800000)) {
// If it's a reasonable timestamp range, treat as date
// Timestamps in seconds are < 4102444800, timestamps in ms are < 4102444800000
if (value < 10000000000) {
// Likely Unix timestamp in seconds (e.g., 1704067200)
type = 'date';
} else if (value < 4102444800000) {
// Could be Unix timestamp in milliseconds (e.g., 1704067200000)
type = 'date';
} else {
// Too large to be a timestamp, treat as number
type = 'number';
}
} else {
type = 'number';
}
} else if (typeof value === 'boolean') {
type = 'boolean';
} else if (value instanceof Date || (typeof value === 'string' && !isNaN(Date.parse(value)) && value.includes('-'))) {
type = 'date';
} else if (isTimestampField && typeof value === 'string') {
// Field name suggests timestamp but value is string - try to parse
const numValue = parseFloat(value);
if (!isNaN(numValue) && numValue > 0 && numValue < 4102444800000) {
type = 'date';
}
}
return {
key,
label: key.charAt(0).toUpperCase() + key.slice(1).replace(/([A-Z])/g, ' $1'),
type,
sortable: true,
filterable: true,
searchable: type === 'string',
width: 150,
minWidth: 100,
maxWidth: 400
};
});
// Cache auto-detected columns
if (autoDetected.length > 0) {
columnsRef.current = autoDetected;
}
return autoDetected;
// Return empty array - table will show no columns
return [];
}, [providedColumns, data]);
// State management
@ -296,6 +239,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
const [sortConfigs, setSortConfigs] = useState<Array<{ key: string; direction: 'asc' | 'desc' }>>([]);
const [filters, setFilters] = useState<Record<string, any>>({});
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
// Actions column width - resizable, default based on number of buttons
const [actionsColumnWidth, setActionsColumnWidth] = useState<number | null>(null);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
@ -391,6 +336,40 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Refs for resizing
const tableRef = useRef<HTMLTableElement>(null);
const tableContainerRef = useRef<HTMLDivElement>(null);
// Track container width for actions column 20% threshold
const [containerWidth, setContainerWidth] = useState<number>(0);
// Calculate default actions column width and track container width
const defaultActionsWidth = useMemo(() => {
return actionButtons.length > 0 ? Math.max(60, actionButtons.length * 32 + 16) : 0;
}, [actionButtons.length]);
// Current actions column width (user-defined or default)
const currentActionsWidth = actionsColumnWidth ?? defaultActionsWidth;
// Check if actions column exceeds 20% of container width (enable wrapping)
const shouldWrapActionButtons = containerWidth > 0 && currentActionsWidth > containerWidth * 0.20;
// Track container width changes
useEffect(() => {
const container = tableContainerRef.current;
if (!container) return;
const updateContainerWidth = () => {
setContainerWidth(container.clientWidth);
};
// Initial measurement
updateContainerWidth();
// Observe resize
const resizeObserver = new ResizeObserver(updateContainerWidth);
resizeObserver.observe(container);
return () => resizeObserver.disconnect();
}, []);
const resizingColumn = useRef<string | null>(null);
const startX = useRef<number>(0);
const startWidth = useRef<number>(0);
@ -843,6 +822,8 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Handle column resizing - use refs to store stable handler references
const handleMouseMoveRef = useRef<((e: MouseEvent) => void) | null>(null);
const handleMouseUpRef = useRef<(() => void) | null>(null);
// Track if we're resizing the actions column
const resizingActionsColumn = useRef<boolean>(false);
const handleMouseDown = (e: React.MouseEvent, columnKey: string) => {
if (!resizable) return;
@ -864,13 +845,14 @@ export function FormGeneratorTable<T extends Record<string, any>>({
// Prevent extending beyond table container
const tableContainer = tableRef.current?.parentElement;
if (tableContainer) {
const containerWidth = tableContainer.clientWidth;
const actionsColumnWidth = actionButtons.length > 0 ? 120 : 0;
const cWidth = tableContainer.clientWidth;
// Calculate actions column width dynamically: ~32px per button + padding
const actionsColWidth = currentActionsWidth;
const selectColumnWidth = selectable ? 50 : 0;
const fixedWidth = actionsColumnWidth + selectColumnWidth;
const fixedWidth = actionsColWidth + selectColumnWidth;
// Maximum allowed width - simple calculation to prevent overflow
const maxAllowedWidth = containerWidth - fixedWidth - 100; // Leave space for other columns
const maxAllowedWidth = cWidth - fixedWidth - 100; // Leave space for other columns
newWidth = Math.min(newWidth, Math.max(100, maxAllowedWidth));
}
@ -908,6 +890,48 @@ export function FormGeneratorTable<T extends Record<string, any>>({
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
};
// Handle actions column resizing (separate handler)
const handleActionsColumnMouseDown = (e: React.MouseEvent) => {
if (!resizable) return;
e.preventDefault();
e.stopPropagation();
resizingActionsColumn.current = true;
startX.current = e.clientX;
startWidth.current = currentActionsWidth;
const mouseMoveHandler = (moveEvent: MouseEvent) => {
if (!resizingActionsColumn.current) return;
const diff = moveEvent.clientX - startX.current;
// Minimum width: default width based on buttons, maximum: 40% of container
const minWidth = defaultActionsWidth;
const maxWidth = containerWidth > 0 ? containerWidth * 0.4 : 400;
const newWidth = Math.max(minWidth, Math.min(maxWidth, startWidth.current + diff));
setActionsColumnWidth(newWidth);
};
const mouseUpHandler = () => {
resizingActionsColumn.current = false;
if (handleMouseMoveRef.current) {
document.removeEventListener('mousemove', handleMouseMoveRef.current);
}
if (handleMouseUpRef.current) {
document.removeEventListener('mouseup', handleMouseUpRef.current);
}
handleMouseMoveRef.current = null;
handleMouseUpRef.current = null;
};
handleMouseMoveRef.current = mouseMoveHandler;
handleMouseUpRef.current = mouseUpHandler;
document.addEventListener('mousemove', mouseMoveHandler);
document.addEventListener('mouseup', mouseUpHandler);
};
// Cleanup on unmount
useEffect(() => {
@ -1181,13 +1205,14 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return column.formatter(value, row);
}
// Check if this is a timestamp field even if column type isn't 'date'
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked)$/i.test(column.key);
const isLikelyTimestamp = typeof value === 'number' && value > 0 && value < 4102444800000;
// Check if this is a timestamp field based on name OR explicit type
// Do NOT treat arbitrary numbers as timestamps - only if field name suggests it
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked|valuta)$/i.test(column.key);
const isExplicitDateType = column.type && isDateTimeType(column.type);
// If it's a timestamp field or looks like a timestamp, format as date
// Also check if column type is a date/time type
if ((isTimestampField || isLikelyTimestamp || (column.type && isDateTimeType(column.type))) && typeof value === 'number') {
// Only format as date if: field name suggests timestamp OR explicit date type
// Do NOT format based on value range alone - this causes amounts/percentages to show as dates
if ((isTimestampField || isExplicitDateType) && typeof value === 'number') {
try {
// Handle Unix timestamps in seconds (backend format)
let timestamp: number;
@ -1343,7 +1368,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
)}
{/* Table */}
<div className={`${styles.tableContainer} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}>
<div
ref={tableContainerRef}
className={`${styles.tableContainer} ${displayData.length === 0 && !loading ? styles.emptyTable : ''}`}
>
{/* Loading overlay - shown while loading */}
{loading && (
<div className={styles.loadingOverlay}>
@ -1380,9 +1408,18 @@ export function FormGeneratorTable<T extends Record<string, any>>({
{actionButtons.length > 0 && (
<th
className={styles.actionsColumn}
style={{ width: '120px', minWidth: '120px', maxWidth: '120px' }}
style={{
width: `${currentActionsWidth}px`,
minWidth: `${defaultActionsWidth}px`,
position: 'relative'
}}
>
{resizable && (
<div
className={styles.resizeHandle}
onMouseDown={handleActionsColumnMouseDown}
/>
)}
</th>
)}
{detectedColumns.map(column => (
@ -1533,7 +1570,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
{actionButtons.length > 0 && (
<td
className={styles.actionsColumn}
style={{ width: '120px', minWidth: '120px', maxWidth: '120px' }}
style={{
width: `${currentActionsWidth}px`,
minWidth: `${defaultActionsWidth}px`
}}
>
<div
ref={(el) => {
@ -1543,7 +1583,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
actionButtonsRefs.current.delete(index);
}
}}
className={styles.actionButtons}
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
>
{/* Standard action buttons (edit, delete, view, copy) */}
{actionButtons.map((actionButton, actionIndex) => {

View file

@ -24,7 +24,7 @@ import { FEATURE_REGISTRY, getLabel } from '../../types/mandate';
import type { Mandate, MandateFeature, FeatureInstance } from '../../types/mandate';
import {
FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserTag,
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube,
FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaChartBar, FaMicrophone,
FaListAlt, FaCogs
} from 'react-icons/fa';
@ -59,10 +59,19 @@ function instanceToTreeNode(
const featureConfig = FEATURE_REGISTRY[featureCode];
const views = featureConfig?.views || [];
// Check if user has _all views permission (full access)
const hasAllViewsPermission = instance.permissions?.views?._all === true;
// Filter views based on permissions
// A view is visible if:
// 1. User has _all views permission, OR
// 2. The specific view permission is explicitly true
const visibleViews = views.filter(view => {
const viewCode = `${featureCode}-${view.code}`;
return instance.permissions?.views?.[viewCode] !== false;
if (hasAllViewsPermission) {
return true;
}
return instance.permissions?.views?.[viewCode] === true;
});
// Convert views to children
@ -192,7 +201,7 @@ export const MandateNavigation: React.FC = () => {
},
{
id: 'workflows-list',
label: 'Workflows',
label: 'Scheduler',
icon: <FaListAlt />,
path: '/workflows/list',
},
@ -302,6 +311,12 @@ export const MandateNavigation: React.FC = () => {
icon: <FaKey />,
path: '/admin/mandate-roles',
},
{
id: 'admin-mandate-role-permissions',
label: 'Rollen-Berechtigungen',
icon: <FaShieldAlt />,
path: '/admin/mandate-role-permissions',
},
{
id: 'admin-user-mandates',
label: 'Mandanten-Mitglieder',

View file

@ -0,0 +1,59 @@
/**
* VoiceLanguageSelect Styles
*
* Compact select component for voice language selection.
* Designed to fit next to icon buttons.
*/
.container {
display: inline-flex;
align-items: center;
}
.select {
height: 36px;
padding: 0 0.5rem;
padding-right: 1.5rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--surface-color);
color: var(--text-primary);
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.2s;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23666' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.4rem center;
min-width: 120px;
}
.select:hover:not(:disabled) {
background-color: var(--bg-secondary);
border-color: var(--primary-color, #f25843);
}
.select:focus {
outline: none;
border-color: var(--primary-color, #f25843);
box-shadow: 0 0 0 2px rgba(242, 88, 67, 0.1);
}
.select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Compact mode - smaller width */
.compact .select {
min-width: 70px;
padding-left: 0.375rem;
font-size: 0.75rem;
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23aaa' d='M6 8L2 4h8z'/%3E%3C/svg%3E");
}
}

View file

@ -0,0 +1,138 @@
/**
* VoiceLanguageSelect
*
* Reusable component for selecting voice/speech recognition language.
* Defaults to user's profile language.
* Can be used for speech-to-text, text-to-speech, and translation features.
*/
import React from 'react';
import { useLanguage } from '../../../providers/language/LanguageContext';
import styles from './VoiceLanguageSelect.module.css';
// Voice language options with full locale codes for Google Cloud Speech
export interface VoiceLanguageOption {
code: string; // Full locale code (e.g., 'de-DE')
label: string; // Display label
shortCode: string; // Short code for mapping (e.g., 'de')
flag?: string; // Optional flag emoji
}
// Supported languages for speech recognition
export const voiceLanguages: VoiceLanguageOption[] = [
{ code: 'de-DE', label: 'Deutsch', shortCode: 'de', flag: '🇩🇪' },
{ code: 'de-CH', label: 'Deutsch (Schweiz)', shortCode: 'de', flag: '🇨🇭' },
{ code: 'en-US', label: 'English (US)', shortCode: 'en', flag: '🇺🇸' },
{ code: 'en-GB', label: 'English (UK)', shortCode: 'en', flag: '🇬🇧' },
{ code: 'fr-FR', label: 'Français', shortCode: 'fr', flag: '🇫🇷' },
{ code: 'fr-CH', label: 'Français (Suisse)', shortCode: 'fr', flag: '🇨🇭' },
{ code: 'it-IT', label: 'Italiano', shortCode: 'it', flag: '🇮🇹' },
{ code: 'it-CH', label: 'Italiano (Svizzera)', shortCode: 'it', flag: '🇨🇭' },
{ code: 'es-ES', label: 'Español', shortCode: 'es', flag: '🇪🇸' },
{ code: 'pt-BR', label: 'Português', shortCode: 'pt', flag: '🇧🇷' },
];
// Map user profile language (short code) to default voice language (full code)
const profileToVoiceLanguage: Record<string, string> = {
'de': 'de-DE',
'en': 'en-US',
'fr': 'fr-FR',
'it': 'it-IT',
'es': 'es-ES',
'pt': 'pt-BR',
};
export interface VoiceLanguageSelectProps {
value: string;
onChange: (languageCode: string) => void;
disabled?: boolean;
compact?: boolean; // Compact mode shows only flag/short code
showFlags?: boolean; // Show flag emojis
className?: string;
title?: string;
}
/**
* Get the default voice language based on user's profile language
*/
export const getDefaultVoiceLanguage = (profileLanguage?: string): string => {
if (profileLanguage && profileToVoiceLanguage[profileLanguage]) {
return profileToVoiceLanguage[profileLanguage];
}
return 'de-DE'; // Default fallback
};
export const VoiceLanguageSelect: React.FC<VoiceLanguageSelectProps> = ({
value,
onChange,
disabled = false,
compact = false,
showFlags = true,
className = '',
title = 'Sprache für Spracherkennung',
}) => {
const { currentLanguage } = useLanguage();
// Get the currently selected language option
const selectedOption = voiceLanguages.find(lang => lang.code === value);
const handleChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
onChange(e.target.value);
};
return (
<div className={`${styles.container} ${compact ? styles.compact : ''} ${className}`}>
<select
className={styles.select}
value={value}
onChange={handleChange}
disabled={disabled}
title={title}
>
{voiceLanguages.map((lang) => (
<option key={lang.code} value={lang.code}>
{showFlags && lang.flag ? `${lang.flag} ` : ''}
{compact ? lang.code.split('-')[0].toUpperCase() : lang.label}
</option>
))}
</select>
</div>
);
};
/**
* Hook to manage voice language state with user profile default
*/
export const useVoiceLanguage = (initialValue?: string) => {
const { currentLanguage } = useLanguage();
// Track if user has manually changed the language
const hasManuallyChanged = React.useRef(false);
// Initialize with user's profile language (or provided initial value)
const [voiceLanguage, setVoiceLanguage] = React.useState<string>(
initialValue || getDefaultVoiceLanguage(currentLanguage)
);
// Update voice language when user profile language changes (only if not manually set)
React.useEffect(() => {
if (!initialValue && !hasManuallyChanged.current) {
const newDefault = getDefaultVoiceLanguage(currentLanguage);
setVoiceLanguage(newDefault);
}
}, [currentLanguage, initialValue]);
// Wrapper to track manual changes
const handleSetVoiceLanguage = React.useCallback((newLanguage: string) => {
hasManuallyChanged.current = true;
setVoiceLanguage(newLanguage);
}, []);
return {
voiceLanguage,
setVoiceLanguage: handleSetVoiceLanguage,
voiceLanguages,
};
};
export default VoiceLanguageSelect;

View file

@ -0,0 +1,8 @@
export {
VoiceLanguageSelect,
useVoiceLanguage,
getDefaultVoiceLanguage,
voiceLanguages,
type VoiceLanguageOption,
type VoiceLanguageSelectProps
} from './VoiceLanguageSelect';

View file

@ -20,3 +20,4 @@ export * from './AutoScroll';
export * from './Tabs';
export type { TabsProps, Tab } from './Tabs';
export * from './Toast';
export * from './VoiceLanguageSelect';

View file

@ -353,12 +353,31 @@ export function useAutomationOperations() {
}, [request]);
// Toggle automation active status
// NOTE: Backend PUT expects full AutomationDefinition object including id
const handleAutomationToggleActive = useCallback(async (
automationId: string,
currentActive: boolean
currentActive: boolean,
fullAutomation?: Automation
): Promise<boolean> => {
try {
await updateAutomationApi(request, automationId, { active: !currentActive });
// Build full update data - backend expects AutomationDefinition with all fields
const sourceAutomation = fullAutomation || await fetchAutomationApi(request, automationId);
// Backend requires id in body to match URL parameter
const updateData = {
id: automationId, // MUST match URL parameter
mandateId: sourceAutomation.mandateId,
featureInstanceId: sourceAutomation.featureInstanceId,
label: sourceAutomation.label,
schedule: sourceAutomation.schedule,
template: typeof sourceAutomation.template === 'object'
? JSON.stringify(sourceAutomation.template)
: sourceAutomation.template,
placeholders: sourceAutomation.placeholders || {},
active: !currentActive
};
await updateAutomationApi(request, automationId, updateData as any);
return true;
} catch (error: any) {
console.error('Error toggling automation active status:', error);
@ -367,21 +386,39 @@ export function useAutomationOperations() {
}, [request]);
// Generic inline update handler for FormGeneratorTable
// NOTE: Backend PUT requires full object, so we merge changes with existing row data
const handleInlineUpdate = useCallback(async (
automationId: string,
changes: Partial<Automation>,
existingRow?: any
existingRow?: Automation
) => {
if (!existingRow) {
throw new Error('Existing row data required for inline update');
}
const result = await handleAutomationUpdate(automationId, changes);
if (!result) {
throw new Error(updateError || 'Failed to update');
try {
// Merge changes with existing row data and send all required fields
const updateData = {
id: automationId, // MUST match URL parameter
mandateId: existingRow.mandateId,
featureInstanceId: existingRow.featureInstanceId,
label: existingRow.label,
schedule: existingRow.schedule,
template: typeof existingRow.template === 'object'
? JSON.stringify(existingRow.template)
: existingRow.template,
placeholders: existingRow.placeholders || {},
// Apply the changes (e.g., active: true/false)
...changes
};
await updateAutomationApi(request, automationId, updateData as any);
return { success: true };
} catch (error: any) {
console.error('Error in inline update:', error);
throw new Error(error.message || 'Failed to update');
}
return { success: true };
}, [handleAutomationUpdate, updateError]);
}, [request]);
// Fetch templates
const fetchTemplates = useCallback(async (): Promise<AutomationTemplate[]> => {

View file

@ -74,6 +74,7 @@ export interface AddUserToInstanceRequest {
export interface FeatureInstanceCreate {
featureCode: string;
label: string;
enabled?: boolean;
copyTemplateRoles?: boolean;
}
@ -211,6 +212,34 @@ export function useFeatureAccess() {
}
}, []);
/**
* Update a feature instance (label, enabled)
*/
const updateInstance = useCallback(async (
mandateId: string,
instanceId: string,
data: { label?: string; enabled?: boolean }
): Promise<{ success: boolean; data?: FeatureInstance; error?: string }> => {
setLoading(true);
setError(null);
try {
const response = await api.put(`/api/features/instances/${instanceId}`, data, {
headers: {
'X-Mandate-Id': mandateId
}
});
// Update local state
setInstances(prev => prev.map(i => i.id === instanceId ? { ...i, ...response.data } : i));
return { success: true, data: response.data };
} catch (err: any) {
const errorMessage = err.response?.data?.detail || err.message || 'Failed to update feature instance';
setError(errorMessage);
return { success: false, error: errorMessage };
} finally {
setLoading(false);
}
}, []);
/**
* Delete a feature instance
*/
@ -450,6 +479,7 @@ export function useFeatureAccess() {
fetchFeatures,
fetchInstances,
createInstance,
updateInstance,
deleteInstance,
syncInstanceRoles,
fetchMyFeatureInstances,

View file

@ -81,10 +81,8 @@
/* Content */
.content {
flex: 1;
overflow: hidden;
overflow: auto;
background: var(--bg-primary, #ffffff);
display: flex;
flex-direction: column;
}
/* Dark Theme */

View file

@ -44,6 +44,18 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
description: 'Ihre E-Mail-Adresse für Benachrichtigungen',
required: true,
placeholder: 'name@example.com'
},
{
name: 'language',
type: 'select',
label: 'Sprache',
description: 'Anzeigesprache der Anwendung',
required: true,
options: [
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
{ value: 'fr', label: 'Français' }
]
}
];
@ -164,13 +176,16 @@ export const SettingsPage: React.FC = () => {
const handleProfileSave = useCallback(async (formData: any) => {
if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet');
// Get the new language (from form or current user)
const newLanguage = formData.language || currentUser.language || 'de';
// Build full user object for update (backend requires full User model)
const userUpdateData = {
id: currentUser.id,
username: currentUser.username,
email: formData.email || currentUser.email,
fullName: formData.fullName || currentUser.fullName,
language: currentUser.language || 'de',
language: newLanguage,
enabled: currentUser.enabled ?? true,
authenticationAuthority: currentUser.authenticationAuthority || 'local'
};
@ -184,10 +199,16 @@ export const SettingsPage: React.FC = () => {
setUserDataCache({
...cachedUser,
fullName: updatedUser.fullName || cachedUser.fullName,
email: updatedUser.email || cachedUser.email
email: updatedUser.email || cachedUser.email,
language: newLanguage
});
}
// Update UI language if changed
if (newLanguage !== currentLanguage) {
setLanguage(newLanguage as 'de' | 'en' | 'fr');
}
// Refetch user data
if (refetchUser) {
await refetchUser();
@ -196,7 +217,7 @@ export const SettingsPage: React.FC = () => {
// Dispatch event to notify other components (e.g., sidebar)
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
}, [currentUser, updateUser, refetchUser]);
}, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]);
return (
<div className={styles.settings}>

View file

@ -6,10 +6,7 @@
.adminPage {
padding: 1.5rem;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 100%;
}
.pageHeader {
@ -637,3 +634,121 @@
:global(.spinning) {
animation: spin 1s linear infinite;
}
/* ============================================== */
/* Role Permissions Page Styles */
/* ============================================== */
/* Scrollable Content Container */
.scrollableContent {
flex: 1;
overflow-y: auto;
min-height: 0;
display: flex;
flex-direction: column;
}
/* Filter Bar */
.filterBar {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin-bottom: 1rem;
flex-shrink: 0;
}
.filterGroup {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filterLabel {
font-size: 0.875rem;
color: var(--text-secondary);
display: flex;
align-items: center;
}
.filterSelect {
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
font-size: 0.875rem;
min-width: 150px;
}
.filterSelect:focus {
outline: none;
border-color: var(--primary-color);
}
.rolesList {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
overflow-y: auto;
min-height: 0;
padding-bottom: 1rem;
}
.roleCard {
background: var(--bg-secondary, #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(--bg-tertiary, #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;
}
.roleContent {
padding: 1rem 1.25rem;
border-top: 1px solid var(--border-color, #e0e0e0);
background: var(--bg-tertiary, #f8f9fa);
}
.emptyHint {
font-size: 0.875rem;
color: var(--text-tertiary, #999);
margin-top: 0.5rem;
}

View file

@ -10,7 +10,7 @@ import { useFeatureAccess, type FeatureInstance } from '../../hooks/useFeatureAc
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs } from 'react-icons/fa';
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import styles from './Admin.module.css';
@ -25,6 +25,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
fetchFeatures,
fetchInstances,
createInstance,
updateInstance,
deleteInstance,
syncInstanceRoles,
} = useFeatureAccess();
@ -36,6 +37,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [showCreateModal, setShowCreateModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editingInstance, setEditingInstance] = useState<FeatureInstance | null>(null);
const [, setIsSubmitting] = useState(false);
const [syncingInstance, setSyncingInstance] = useState<string | null>(null);
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
@ -78,7 +81,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
// Form attributes from backend - merge with dynamic feature options
const createFields: AttributeDefinition[] = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'enabled'];
const excludedFields = ['id', 'mandateId'];
const featureOptions = features.map(f => ({
value: f.code,
label: typeof f.label === 'object'
@ -92,19 +95,20 @@ export const AdminFeatureAccessPage: React.FC = () => {
...attr,
// Override featureCode: make editable for create and add dynamic options
readonly: attr.name === 'featureCode' ? false : attr.readonly,
editable: attr.name === 'featureCode' ? true : attr.editable,
editable: attr.name === 'featureCode' || attr.name === 'enabled' ? true : attr.editable,
options: attr.name === 'featureCode' ? featureOptions : attr.options,
})) as AttributeDefinition[];
}, [features, backendAttributes]);
// Handle create instance
const handleCreateInstance = async (data: { featureCode: string; label: string; copyTemplateRoles?: boolean }) => {
const handleCreateInstance = async (data: { featureCode: string; label: string; enabled?: boolean; copyTemplateRoles?: boolean }) => {
if (!selectedMandateId) return;
setIsSubmitting(true);
try {
const result = await createInstance(selectedMandateId, {
featureCode: data.featureCode,
label: data.label,
enabled: data.enabled !== false,
copyTemplateRoles: data.copyTemplateRoles !== false
});
if (result.success) {
@ -119,16 +123,44 @@ export const AdminFeatureAccessPage: React.FC = () => {
}
};
// Handle delete instance
const handleDeleteInstance = async (instance: FeatureInstance) => {
if (!selectedMandateId) return;
if (window.confirm(`Möchten Sie die Feature-Instanz "${instance.label}" wirklich löschen? Alle zugehörigen Daten werden gelöscht.`)) {
const result = await deleteInstance(selectedMandateId, instance.id);
// Handle edit click
const handleEditClick = (instance: FeatureInstance) => {
setEditingInstance(instance);
setShowEditModal(true);
};
// Handle update instance
const handleUpdateInstance = async (data: { label: string; enabled?: boolean }) => {
if (!selectedMandateId || !editingInstance) return;
setIsSubmitting(true);
try {
const result = await updateInstance(selectedMandateId, editingInstance.id, {
label: data.label,
enabled: data.enabled
});
if (result.success) {
showSuccess('Instanz gelöscht', `Die Feature-Instanz "${instance.label}" wurde gelöscht.`);
setShowEditModal(false);
setEditingInstance(null);
fetchInstances(selectedMandateId);
showSuccess('Feature-Instanz aktualisiert', `Die Instanz "${data.label}" wurde erfolgreich aktualisiert.`);
} else {
showError('Fehler', result.error || 'Fehler beim Löschen der Feature-Instanz');
showError('Fehler', result.error || 'Fehler beim Aktualisieren der Feature-Instanz');
}
} finally {
setIsSubmitting(false);
}
};
// Handle delete instance - called by DeleteActionButton with instanceId
const handleDeleteInstance = async (instanceId: string): Promise<boolean> => {
if (!selectedMandateId) return false;
const result = await deleteInstance(selectedMandateId, instanceId);
if (result.success) {
showSuccess('Instanz gelöscht', 'Die Feature-Instanz wurde gelöscht.');
return true;
} else {
showError('Fehler', result.error || 'Fehler beim Löschen der Feature-Instanz');
return false;
}
};
@ -296,15 +328,21 @@ export const AdminFeatureAccessPage: React.FC = () => {
}
]}
customActions={[
{
id: 'edit',
icon: <FaEdit />,
onClick: handleEditClick,
title: 'Instanz bearbeiten',
},
{
id: 'syncRoles',
icon: <FaCogs />,
onClick: handleSyncRoles,
title: 'Rollen synchronisieren',
loading: (row: FeatureInstance) => syncingInstance === row.id,
disabled: (row: FeatureInstance) => !row.enabled,
}
]}
onDelete={handleDeleteInstance}
hookData={{
refetch: fetchInstances,
pagination: instancesPagination,
@ -350,6 +388,49 @@ export const AdminFeatureAccessPage: React.FC = () => {
</div>
</div>
)}
{/* Edit Instance Modal */}
{showEditModal && editingInstance && (
<div className={styles.modalOverlay} onClick={() => { setShowEditModal(false); setEditingInstance(null); }}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>Feature-Instanz bearbeiten</h2>
<button
className={styles.modalClose}
onClick={() => { setShowEditModal(false); setEditingInstance(null); }}
>
</button>
</div>
<div className={styles.modalContent}>
<FormGeneratorForm
attributes={[
{
name: 'label',
type: 'string' as const,
label: 'Bezeichnung',
required: true,
editable: true,
},
{
name: 'enabled',
type: 'boolean' as const,
label: 'Aktiviert',
required: false,
editable: true,
}
]}
data={editingInstance}
mode="edit"
onSubmit={handleUpdateInstance}
onCancel={() => { setShowEditModal(false); setEditingInstance(null); }}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
</div>
</div>
</div>
)}
</div>
);
};

View file

@ -0,0 +1,263 @@
/**
* AdminMandateRolePermissionsPage
*
* Admin page for managing access rules (permissions) for mandate-level roles.
* Similar to TrusteeInstanceRolesView but for mandate/global roles.
*
* Shows:
* - System roles (admin, user, viewer) - read-only permissions
* - Global roles (mandateId=null) - editable permissions
* - Mandate-specific roles (mandateId=xyz) - editable permissions
*
* Each role can be expanded to show/edit its AccessRules via AccessRulesEditor.
*/
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { useMandateRoles, type Role } from '../../hooks/useMandateRoles';
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { AccessRulesEditor } from '../../components/AccessRules';
import {
FaUserShield,
FaShieldAlt,
FaSync,
FaChevronDown,
FaChevronRight,
FaGlobe,
FaBuilding,
FaFilter
} from 'react-icons/fa';
import styles from './Admin.module.css';
export const AdminMandateRolePermissionsPage: React.FC = () => {
const {
roles,
loading,
error,
fetchRoles,
} = useMandateRoles();
const { fetchMandates } = useUserMandates();
// State
const [mandates, setMandates] = useState<Mandate[]>([]);
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all');
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
// Load mandates on mount
useEffect(() => {
const loadMandates = async () => {
const data = await fetchMandates();
setMandates(data);
if (data.length > 0 && !selectedMandateId) {
setSelectedMandateId(data[0].id);
}
};
loadMandates();
}, [fetchMandates]);
// Load roles when mandate or scopeFilter changes
useEffect(() => {
if (selectedMandateId) {
fetchRoles(selectedMandateId, { scopeFilter });
}
}, [selectedMandateId, scopeFilter, fetchRoles]);
// Refetch roles
const handleRefresh = useCallback(async () => {
if (selectedMandateId) {
await fetchRoles(selectedMandateId, { scopeFilter });
}
}, [selectedMandateId, scopeFilter, fetchRoles]);
// Get description 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] || '';
};
// Toggle role expansion
const toggleRole = (roleId: string) => {
setExpandedRoleId(prev => prev === roleId ? null : roleId);
};
// Get scope badge
const getScopeBadge = (role: Role) => {
if (role.isSystemRole) {
return (
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
<FaUserShield style={{ marginRight: 4 }} /> System
</span>
);
}
if (!role.mandateId) {
return (
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
<FaGlobe style={{ marginRight: 4 }} /> Global
</span>
);
}
return (
<span className={styles.badge} style={{ background: 'var(--success-color, #38a169)', color: 'white' }}>
<FaBuilding style={{ marginRight: 4 }} /> Mandant
</span>
);
};
// Filter options for scope
const scopeOptions = useMemo(() => [
{ value: 'all', label: 'Alle Rollen' },
{ value: 'mandate', label: 'Nur Mandanten-Rollen' },
{ value: 'global', label: 'Nur globale Rollen' },
], []);
if (error) {
return (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>Fehler beim Laden: {error}</p>
<button className={styles.secondaryButton} onClick={handleRefresh}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
{/* Header */}
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
Rollen-Berechtigungen
</h1>
<p className={styles.pageSubtitle}>
Verwalten Sie die Zugriffsrechte für Mandanten- und globale Rollen
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={handleRefresh}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
</div>
</div>
{/* Filters */}
<div className={styles.filterBar}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>Mandant:</label>
<select
className={styles.filterSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
>
{mandates.map(mandate => (
<option key={mandate.id} value={mandate.id}>
{getTextValue(mandate.name)}
</option>
))}
</select>
</div>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaFilter style={{ marginRight: 4 }} /> Bereich:
</label>
<select
className={styles.filterSelect}
value={scopeFilter}
onChange={(e) => setScopeFilter(e.target.value as 'all' | 'mandate' | 'global')}
>
{scopeOptions.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
</div>
{/* Info Box */}
<div className={styles.infoBox}>
<FaShieldAlt style={{ marginRight: '0.5rem' }} />
<span>
Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.
System-Rollen sind schreibgeschützt.
</span>
</div>
{/* Loading State */}
{loading && (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Rollen...</span>
</div>
)}
{/* Empty State */}
{!loading && roles.length === 0 && (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<p>Keine Rollen gefunden</p>
<p className={styles.emptyHint}>
{scopeFilter === 'mandate'
? 'Es gibt noch keine mandantenspezifischen Rollen.'
: scopeFilter === 'global'
? 'Es gibt noch keine globalen Rollen.'
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
</p>
</div>
)}
{/* Roles List */}
{!loading && roles.length > 0 && (
<div className={styles.rolesList}>
{roles.map(role => (
<div key={role.id} className={styles.roleCard}>
{/* Role Header - Clickable to expand */}
<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}>
{getScopeBadge(role)}
</div>
</div>
{/* Expanded Content - AccessRulesEditor */}
{expandedRoleId === role.id && (
<div className={styles.roleContent}>
<AccessRulesEditor
roleId={role.id}
roleName={role.roleLabel}
isTemplate={false}
readOnly={role.isSystemRole}
apiBasePath="/api/rbac"
mandateId={selectedMandateId}
/>
</div>
)}
</div>
))}
</div>
)}
</div>
);
};
export default AdminMandateRolePermissionsPage;

View file

@ -11,4 +11,5 @@ export { AdminFeatureAccessPage } from './AdminFeatureAccessPage';
export { AdminInvitationsPage } from './AdminInvitationsPage';
export { AdminMandateRolesPage } from './AdminMandateRolesPage';
export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';

View file

@ -300,6 +300,7 @@ export const TrusteeDocumentsView: React.FC = () => {
onCancel={handleCloseModal}
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
cancelButtonText="Abbrechen"
instanceId={instanceId}
/>
)}
</div>

View file

@ -220,6 +220,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
onCancel={handleCloseModal}
submitButtonText="Verknüpfung erstellen"
cancelButtonText="Abbrechen"
instanceId={instanceId}
/>
)}
</div>

View file

@ -275,6 +275,7 @@ export const TrusteePositionsView: React.FC = () => {
onCancel={handleCloseModal}
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
cancelButtonText="Abbrechen"
instanceId={instanceId}
/>
)}
</div>

View file

@ -9,7 +9,7 @@ import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
import { FaSync, FaRobot, FaRocket, FaPlus, FaPauseCircle, FaPlayCircle, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner } from 'react-icons/fa';
import { FaSync, FaRobot, FaRocket, FaPlus, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi';
import { useFeatureStore } from '../../stores/featureStore';
@ -54,7 +54,6 @@ export const AutomationsPage: React.FC = () => {
handleAutomationUpdate,
handleAutomationDelete,
handleAutomationExecute,
handleAutomationToggleActive,
handleInlineUpdate,
fetchTemplates,
deletingAutomations,
@ -411,19 +410,6 @@ export const AutomationsPage: React.FC = () => {
});
};
// Handle toggle active
const handleToggleActive = async (automation: Automation) => {
updateOptimistically(automation.id, { active: !automation.active });
const success = await handleAutomationToggleActive(automation.id, automation.active);
if (success) {
showSuccess(automation.active ? 'Automatisierung deaktiviert' : 'Automatisierung aktiviert');
} else {
updateOptimistically(automation.id, { active: automation.active });
showError('Fehler beim Ändern des Status');
}
};
// Show logs modal
const handleShowLogs = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
@ -582,12 +568,6 @@ export const AutomationsPage: React.FC = () => {
title: 'Ausführen',
loading: (row: any) => executingAutomations.has(row.id),
},
{
id: 'toggleActive',
icon: (row: any) => row.active ? <FaPauseCircle /> : <FaPlayCircle />,
onClick: handleToggleActive,
title: (row: any) => row.active ? 'Deaktivieren' : 'Aktivieren',
} as any,
{
id: 'logs',
icon: <FaList />,

View file

@ -14,6 +14,7 @@ import { useResizablePanels } from '../../hooks/useResizablePanels';
import { usePrompts } from '../../hooks/usePrompts';
import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useVoiceLanguage, VoiceLanguageSelect } from '../../components/UiComponents';
import api from '../../api';
import styles from './PlaygroundPage.module.css';
@ -76,6 +77,9 @@ export const PlaygroundPage: React.FC = () => {
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
const [audioChunks, setAudioChunks] = useState<Blob[]>([]);
// Voice language selection (defaults to user profile language)
const { voiceLanguage, setVoiceLanguage } = useVoiceLanguage();
// Prompts dropdown state
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
@ -243,11 +247,11 @@ export const PlaygroundPage: React.FC = () => {
try {
// Create FormData for speech-to-text API
const formData = new FormData();
formData.append('file', audioBlob, 'voice_recording.webm');
formData.append('language', 'de-DE');
formData.append('audioFile', audioBlob, 'voice_recording.webm');
formData.append('language', voiceLanguage);
// Call speech-to-text API
const response = await api.post('/api/ai/speech-to-text', formData, {
// Call speech-to-text API (Google Cloud)
const response = await api.post('/voice-google/speech-to-text', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
@ -799,6 +803,13 @@ export const PlaygroundPage: React.FC = () => {
>
<FaPlus />
</button>
<VoiceLanguageSelect
value={voiceLanguage}
onChange={setVoiceLanguage}
disabled={isRecording}
compact={true}
title="Sprache für Spracherkennung"
/>
<button
className={`${styles.iconButton} ${isRecording ? styles.recording : ''}`}
onClick={handleVoiceClick}

View file

@ -65,7 +65,12 @@ export interface InstancePermissions {
fields?: Record<string, Record<string, FieldPermission>>;
// View-Level (Navigation)
// Keys are view codes like "trustee-positions", values are boolean visibility
// Special key "_all" means all views are visible
views: Record<string, boolean>;
// Admin flag (has admin role in this instance)
isAdmin?: boolean;
}
// =============================================================================