fixes
This commit is contained in:
parent
5952074626
commit
6a406d885d
25 changed files with 1113 additions and 241 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
8
src/components/UiComponents/VoiceLanguageSelect/index.ts
Normal file
8
src/components/UiComponents/VoiceLanguageSelect/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export {
|
||||
VoiceLanguageSelect,
|
||||
useVoiceLanguage,
|
||||
getDefaultVoiceLanguage,
|
||||
voiceLanguages,
|
||||
type VoiceLanguageOption,
|
||||
type VoiceLanguageSelectProps
|
||||
} from './VoiceLanguageSelect';
|
||||
|
|
@ -20,3 +20,4 @@ export * from './AutoScroll';
|
|||
export * from './Tabs';
|
||||
export type { TabsProps, Tab } from './Tabs';
|
||||
export * from './Toast';
|
||||
export * from './VoiceLanguageSelect';
|
||||
|
|
|
|||
|
|
@ -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[]> => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -81,10 +81,8 @@
|
|||
/* Content */
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
overflow: auto;
|
||||
background: var(--bg-primary, #ffffff);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Dark Theme */
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
263
src/pages/admin/AdminMandateRolePermissionsPage.tsx
Normal file
263
src/pages/admin/AdminMandateRolePermissionsPage.tsx
Normal 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;
|
||||
|
|
@ -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';
|
||||
|
|
@ -300,6 +300,7 @@ export const TrusteeDocumentsView: React.FC = () => {
|
|||
onCancel={handleCloseModal}
|
||||
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
||||
cancelButtonText="Abbrechen"
|
||||
instanceId={instanceId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
|||
onCancel={handleCloseModal}
|
||||
submitButtonText="Verknüpfung erstellen"
|
||||
cancelButtonText="Abbrechen"
|
||||
instanceId={instanceId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -275,6 +275,7 @@ export const TrusteePositionsView: React.FC = () => {
|
|||
onCancel={handleCloseModal}
|
||||
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
||||
cancelButtonText="Abbrechen"
|
||||
instanceId={instanceId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue