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 { DashboardPage } from './pages/Dashboard';
|
||||||
import { SettingsPage } from './pages/Settings';
|
import { SettingsPage } from './pages/Settings';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
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)
|
// Workflow Pages (global)
|
||||||
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
|
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
|
||||||
|
|
@ -168,6 +168,7 @@ function App() {
|
||||||
<Route path="feature-users" element={<AdminFeatureInstanceUsersPage />} />
|
<Route path="feature-users" element={<AdminFeatureInstanceUsersPage />} />
|
||||||
<Route path="invitations" element={<AdminInvitationsPage />} />
|
<Route path="invitations" element={<AdminInvitationsPage />} />
|
||||||
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
|
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
|
||||||
|
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -519,10 +519,29 @@ export async function createDocument(
|
||||||
instanceId: string,
|
instanceId: string,
|
||||||
data: Partial<TrusteeDocument>
|
data: Partial<TrusteeDocument>
|
||||||
): Promise<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({
|
return await request({
|
||||||
url: `${_getTrusteeBaseUrl(instanceId)}/documents`,
|
url: `${_getTrusteeBaseUrl(instanceId)}/documents`,
|
||||||
method: 'post',
|
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 { useLanguage } from '../../../providers/language/LanguageContext';
|
||||||
import api from '../../../api';
|
import api from '../../../api';
|
||||||
import styles from './FormGeneratorForm.module.css';
|
import styles from './FormGeneratorForm.module.css';
|
||||||
|
|
@ -105,8 +105,19 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
}: FormGeneratorFormProps<T>) {
|
}: FormGeneratorFormProps<T>) {
|
||||||
const { t } = useLanguage();
|
const { t } = useLanguage();
|
||||||
|
|
||||||
// Helper to resolve API paths with {instanceId} placeholder
|
const [formData, setFormData] = useState<T>(data || {} as T);
|
||||||
const resolveApiPath = (path: string): string => {
|
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}')) {
|
if (path.includes('{instanceId}')) {
|
||||||
const resolvedInstanceId = instanceId || (data as any)?.featureInstanceId;
|
const resolvedInstanceId = instanceId || (data as any)?.featureInstanceId;
|
||||||
if (!resolvedInstanceId) {
|
if (!resolvedInstanceId) {
|
||||||
|
|
@ -116,15 +127,7 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
return path.replace('{instanceId}', resolvedInstanceId);
|
return path.replace('{instanceId}', resolvedInstanceId);
|
||||||
}
|
}
|
||||||
return path;
|
return path;
|
||||||
};
|
}, [instanceId, data]);
|
||||||
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);
|
|
||||||
|
|
||||||
// Fetch attributes from backend
|
// Fetch attributes from backend
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -252,26 +255,39 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
|
|
||||||
// Fetch options for fields with optionsReference (API path)
|
// Fetch options for fields with optionsReference (API path)
|
||||||
// Backend provides options in standardized format: { value, label }
|
// Backend provides options in standardized format: { value, label }
|
||||||
|
// OPTIMIZED: Only fetch options that are not already fetched or being fetched
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchOptions = async () => {
|
const fetchOptions = async () => {
|
||||||
const filteredAttrs = getFilteredAttributes();
|
const filteredAttrs = getFilteredAttributes();
|
||||||
const fieldsToFetch = filteredAttrs.filter(attr => {
|
|
||||||
if (typeof attr.options === 'string' && !optionsCache[attr.options]) {
|
// Collect unique option keys that need fetching
|
||||||
return true;
|
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) {
|
// Set loading state for relevant fields
|
||||||
if (typeof field.options !== 'string') continue;
|
const fieldsLoading: Record<string, boolean> = {};
|
||||||
|
filteredAttrs.forEach(attr => {
|
||||||
const optionKey = field.options;
|
if (typeof attr.options === 'string' && optionKeysToFetch.includes(attr.options)) {
|
||||||
setLoadingOptions(prev => ({ ...prev, [field.name]: true }));
|
fieldsLoading[attr.name] = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setLoadingOptions(prev => ({ ...prev, ...fieldsLoading }));
|
||||||
|
|
||||||
|
// Fetch all options in parallel
|
||||||
|
const fetchPromises = optionKeysToFetch.map(async (optionKey) => {
|
||||||
try {
|
try {
|
||||||
// Backend provides full API path (e.g., "/api/connections/statuses/options")
|
|
||||||
// Resolve {instanceId} placeholder if present
|
// Resolve {instanceId} placeholder if present
|
||||||
const apiPath = resolveApiPath(optionKey);
|
const apiPath = resolveApiPath(optionKey);
|
||||||
const response = await api.get(apiPath);
|
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) {
|
} catch (error: any) {
|
||||||
console.error(`Failed to fetch options for ${field.options}:`, error);
|
console.error(`Failed to fetch options for ${optionKey}:`, error);
|
||||||
setOptionsCache(prev => ({ ...prev, [field.options as string]: [] }));
|
return { key: optionKey, options: [], error };
|
||||||
} finally {
|
|
||||||
setLoadingOptions(prev => ({ ...prev, [field.name]: false }));
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// 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();
|
fetchOptions();
|
||||||
}, [getFilteredAttributes, optionsCache, resolveApiPath]);
|
}, [getFilteredAttributes, resolveApiPath]);
|
||||||
|
|
||||||
// Handle field focus
|
// Handle field focus
|
||||||
const handleFieldFocus = (fieldName: string, focused: boolean) => {
|
const handleFieldFocus = (fieldName: string, focused: boolean) => {
|
||||||
|
|
@ -314,10 +349,22 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle field value changes
|
// 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 => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[fieldName]: value
|
[fieldName]: processedValue
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Clear error for this field when user starts typing
|
// 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
|
// Normalize options for a field
|
||||||
const normalizeOptions = (attr: AttributeDefinition): Array<{ value: string | number; label: string }> => {
|
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
|
// Timestamp/Date validation
|
||||||
if (isDateTimeType(attr.type)) {
|
if (isDateTimeType(attr.type)) {
|
||||||
const dateValue = new Date(String(value));
|
// For timestamp fields, value is stored as Unix timestamp (float)
|
||||||
if (isNaN(dateValue.getTime())) {
|
// 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');
|
newErrors[attr.name] = t('formgen.form.invalidDate', 'Invalid date format');
|
||||||
return;
|
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);
|
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 (
|
return (
|
||||||
<div className={styles.floatingLabelInput} key={attr.name}>
|
<div className={styles.floatingLabelInput} key={attr.name}>
|
||||||
<input
|
<input
|
||||||
type={inputType}
|
type={inputType}
|
||||||
value={value || ''}
|
value={displayValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
let newValue: any = e.target.value;
|
let newValue: any = e.target.value;
|
||||||
if (isNumberType(attr.type)) {
|
if (isNumberType(attr.type)) {
|
||||||
newValue = e.target.value === '' ? '' : Number(e.target.value);
|
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)}
|
onFocus={() => handleFieldFocus(attr.name, true)}
|
||||||
onBlur={() => handleFieldFocus(attr.name, false)}
|
onBlur={() => handleFieldFocus(attr.name, false)}
|
||||||
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
|
className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`}
|
||||||
/>
|
/>
|
||||||
<label className={getLabelClass(attr.name, value)}>
|
<label className={getLabelClass(attr.name, displayValue)}>
|
||||||
{attr.label}
|
{attr.label}
|
||||||
{attr.required && <span className={styles.required}>*</span>}
|
{attr.required && <span className={styles.required}>*</span>}
|
||||||
</label>
|
</label>
|
||||||
|
|
|
||||||
|
|
@ -111,72 +111,32 @@ export function FormGeneratorList<T extends Record<string, any>>({
|
||||||
}: FormGeneratorListProps<T>) {
|
}: FormGeneratorListProps<T>) {
|
||||||
const { t } = useLanguage();
|
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 fieldsRef = useRef<FieldConfig[]>([]);
|
||||||
|
|
||||||
const detectedFields = useMemo((): FieldConfig[] => {
|
const detectedFields = useMemo((): FieldConfig[] => {
|
||||||
// Always use providedFields if available
|
// Use providedFields from Pydantic attribute definitions
|
||||||
if (providedFields && providedFields.length > 0) {
|
if (providedFields && providedFields.length > 0) {
|
||||||
fieldsRef.current = providedFields;
|
fieldsRef.current = providedFields;
|
||||||
return providedFields;
|
return providedFields;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have cached fields and no new fields provided, use cached fields
|
// Use cached fields if data becomes empty (e.g., after filtering)
|
||||||
if (fieldsRef.current.length > 0 && data.length === 0) {
|
if (fieldsRef.current.length > 0) {
|
||||||
return fieldsRef.current;
|
return fieldsRef.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only auto-detect if no fields provided AND we have data
|
// NO FIELDS PROVIDED - this is an error in the calling component
|
||||||
if (data.length === 0) {
|
// The calling component should provide fields from the /attributes/{entityType} endpoint
|
||||||
return fieldsRef.current;
|
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];
|
// Return empty array - list will show no fields
|
||||||
const autoDetected = Object.keys(sampleRow).map(key => {
|
return [];
|
||||||
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;
|
|
||||||
}, [providedFields, data]);
|
}, [providedFields, data]);
|
||||||
|
|
||||||
// State management
|
// State management
|
||||||
|
|
|
||||||
|
|
@ -409,6 +409,7 @@ tbody .actionsColumn {
|
||||||
|
|
||||||
.actionButtons {
|
.actionButtons {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: nowrap;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -416,6 +417,11 @@ tbody .actionsColumn {
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Enable wrapping only when column exceeds 20% of container width */
|
||||||
|
.actionButtonsWrap {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.actionButton {
|
.actionButton {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -202,90 +202,33 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
}
|
}
|
||||||
return 'en'; // Default to English
|
return 'en'; // Default to English
|
||||||
}, []);
|
}, []);
|
||||||
// Use provided columns (from attributes) if available, otherwise auto-detect from data
|
// Use provided columns from Pydantic attribute definitions
|
||||||
// Columns should persist even when data is empty (e.g., after filtering)
|
// NO AUTO-DETECTION - columns must come from backend attribute definitions
|
||||||
// Use a ref to cache columns so they persist across data changes
|
// Use a ref to cache columns so they persist across data changes (e.g., when filtering)
|
||||||
const columnsRef = useRef<ColumnConfig[]>([]);
|
const columnsRef = useRef<ColumnConfig[]>([]);
|
||||||
|
|
||||||
const detectedColumns = useMemo((): ColumnConfig[] => {
|
const detectedColumns = useMemo((): ColumnConfig[] => {
|
||||||
// Always use providedColumns if available (from attributes/hookData.columns)
|
// Use providedColumns from Pydantic attribute definitions
|
||||||
// This ensures columns persist even when data is empty
|
|
||||||
if (providedColumns && providedColumns.length > 0) {
|
if (providedColumns && providedColumns.length > 0) {
|
||||||
columnsRef.current = providedColumns;
|
columnsRef.current = providedColumns;
|
||||||
return providedColumns;
|
return providedColumns;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we have cached columns and no new columns provided, use cached columns
|
// Use cached columns if data becomes empty (e.g., after filtering)
|
||||||
// This prevents columns from disappearing when data becomes empty
|
if (columnsRef.current.length > 0) {
|
||||||
if (columnsRef.current.length > 0 && data.length === 0) {
|
|
||||||
return columnsRef.current;
|
return columnsRef.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only auto-detect if no columns provided AND we have data
|
// NO COLUMNS PROVIDED - this is an error in the calling component
|
||||||
if (data.length === 0) {
|
// The calling component should provide columns from the /attributes/{entityType} endpoint
|
||||||
// Return cached columns if available, otherwise empty array
|
console.warn(
|
||||||
return columnsRef.current;
|
'⚠️ 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];
|
// Return empty array - table will show no columns
|
||||||
const autoDetected = Object.keys(sampleRow).map(key => {
|
return [];
|
||||||
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;
|
|
||||||
}, [providedColumns, data]);
|
}, [providedColumns, data]);
|
||||||
|
|
||||||
// State management
|
// 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 [sortConfigs, setSortConfigs] = useState<Array<{ key: string; direction: 'asc' | 'desc' }>>([]);
|
||||||
const [filters, setFilters] = useState<Record<string, any>>({});
|
const [filters, setFilters] = useState<Record<string, any>>({});
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>({});
|
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 [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
|
const [currentPageSize, setCurrentPageSize] = useState(pageSize);
|
||||||
|
|
@ -391,6 +336,40 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
|
|
||||||
// Refs for resizing
|
// Refs for resizing
|
||||||
const tableRef = useRef<HTMLTableElement>(null);
|
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 resizingColumn = useRef<string | null>(null);
|
||||||
const startX = useRef<number>(0);
|
const startX = useRef<number>(0);
|
||||||
const startWidth = 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
|
// Handle column resizing - use refs to store stable handler references
|
||||||
const handleMouseMoveRef = useRef<((e: MouseEvent) => void) | null>(null);
|
const handleMouseMoveRef = useRef<((e: MouseEvent) => void) | null>(null);
|
||||||
const handleMouseUpRef = useRef<(() => 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) => {
|
const handleMouseDown = (e: React.MouseEvent, columnKey: string) => {
|
||||||
if (!resizable) return;
|
if (!resizable) return;
|
||||||
|
|
@ -864,13 +845,14 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
// Prevent extending beyond table container
|
// Prevent extending beyond table container
|
||||||
const tableContainer = tableRef.current?.parentElement;
|
const tableContainer = tableRef.current?.parentElement;
|
||||||
if (tableContainer) {
|
if (tableContainer) {
|
||||||
const containerWidth = tableContainer.clientWidth;
|
const cWidth = tableContainer.clientWidth;
|
||||||
const actionsColumnWidth = actionButtons.length > 0 ? 120 : 0;
|
// Calculate actions column width dynamically: ~32px per button + padding
|
||||||
|
const actionsColWidth = currentActionsWidth;
|
||||||
const selectColumnWidth = selectable ? 50 : 0;
|
const selectColumnWidth = selectable ? 50 : 0;
|
||||||
const fixedWidth = actionsColumnWidth + selectColumnWidth;
|
const fixedWidth = actionsColWidth + selectColumnWidth;
|
||||||
|
|
||||||
// Maximum allowed width - simple calculation to prevent overflow
|
// 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));
|
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('mousemove', mouseMoveHandler);
|
||||||
document.addEventListener('mouseup', mouseUpHandler);
|
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
|
// Cleanup on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1181,13 +1205,14 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
return column.formatter(value, row);
|
return column.formatter(value, row);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this is a timestamp field even if column type isn't 'date'
|
// Check if this is a timestamp field based on name OR explicit type
|
||||||
const isTimestampField = /(at|date|time|timestamp|created|updated|expires|checked)$/i.test(column.key);
|
// Do NOT treat arbitrary numbers as timestamps - only if field name suggests it
|
||||||
const isLikelyTimestamp = typeof value === 'number' && value > 0 && value < 4102444800000;
|
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
|
// Only format as date if: field name suggests timestamp OR explicit date type
|
||||||
// Also check if column type is a date/time type
|
// Do NOT format based on value range alone - this causes amounts/percentages to show as dates
|
||||||
if ((isTimestampField || isLikelyTimestamp || (column.type && isDateTimeType(column.type))) && typeof value === 'number') {
|
if ((isTimestampField || isExplicitDateType) && typeof value === 'number') {
|
||||||
try {
|
try {
|
||||||
// Handle Unix timestamps in seconds (backend format)
|
// Handle Unix timestamps in seconds (backend format)
|
||||||
let timestamp: number;
|
let timestamp: number;
|
||||||
|
|
@ -1343,7 +1368,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* 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 overlay - shown while loading */}
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className={styles.loadingOverlay}>
|
<div className={styles.loadingOverlay}>
|
||||||
|
|
@ -1380,9 +1408,18 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
{actionButtons.length > 0 && (
|
{actionButtons.length > 0 && (
|
||||||
<th
|
<th
|
||||||
className={styles.actionsColumn}
|
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>
|
</th>
|
||||||
)}
|
)}
|
||||||
{detectedColumns.map(column => (
|
{detectedColumns.map(column => (
|
||||||
|
|
@ -1533,7 +1570,10 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
{actionButtons.length > 0 && (
|
{actionButtons.length > 0 && (
|
||||||
<td
|
<td
|
||||||
className={styles.actionsColumn}
|
className={styles.actionsColumn}
|
||||||
style={{ width: '120px', minWidth: '120px', maxWidth: '120px' }}
|
style={{
|
||||||
|
width: `${currentActionsWidth}px`,
|
||||||
|
minWidth: `${defaultActionsWidth}px`
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={(el) => {
|
ref={(el) => {
|
||||||
|
|
@ -1543,7 +1583,7 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
||||||
actionButtonsRefs.current.delete(index);
|
actionButtonsRefs.current.delete(index);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={styles.actionButtons}
|
className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
|
||||||
>
|
>
|
||||||
{/* Standard action buttons (edit, delete, view, copy) */}
|
{/* Standard action buttons (edit, delete, view, copy) */}
|
||||||
{actionButtons.map((actionButton, actionIndex) => {
|
{actionButtons.map((actionButton, actionIndex) => {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ import { FEATURE_REGISTRY, getLabel } from '../../types/mandate';
|
||||||
import type { Mandate, MandateFeature, FeatureInstance } from '../../types/mandate';
|
import type { Mandate, MandateFeature, FeatureInstance } from '../../types/mandate';
|
||||||
import {
|
import {
|
||||||
FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserTag,
|
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,
|
FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaChartBar, FaMicrophone,
|
||||||
FaListAlt, FaCogs
|
FaListAlt, FaCogs
|
||||||
} from 'react-icons/fa';
|
} from 'react-icons/fa';
|
||||||
|
|
@ -59,10 +59,19 @@ function instanceToTreeNode(
|
||||||
const featureConfig = FEATURE_REGISTRY[featureCode];
|
const featureConfig = FEATURE_REGISTRY[featureCode];
|
||||||
const views = featureConfig?.views || [];
|
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
|
// 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 visibleViews = views.filter(view => {
|
||||||
const viewCode = `${featureCode}-${view.code}`;
|
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
|
// Convert views to children
|
||||||
|
|
@ -192,7 +201,7 @@ export const MandateNavigation: React.FC = () => {
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'workflows-list',
|
id: 'workflows-list',
|
||||||
label: 'Workflows',
|
label: 'Scheduler',
|
||||||
icon: <FaListAlt />,
|
icon: <FaListAlt />,
|
||||||
path: '/workflows/list',
|
path: '/workflows/list',
|
||||||
},
|
},
|
||||||
|
|
@ -302,6 +311,12 @@ export const MandateNavigation: React.FC = () => {
|
||||||
icon: <FaKey />,
|
icon: <FaKey />,
|
||||||
path: '/admin/mandate-roles',
|
path: '/admin/mandate-roles',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'admin-mandate-role-permissions',
|
||||||
|
label: 'Rollen-Berechtigungen',
|
||||||
|
icon: <FaShieldAlt />,
|
||||||
|
path: '/admin/mandate-role-permissions',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'admin-user-mandates',
|
id: 'admin-user-mandates',
|
||||||
label: 'Mandanten-Mitglieder',
|
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 * from './Tabs';
|
||||||
export type { TabsProps, Tab } from './Tabs';
|
export type { TabsProps, Tab } from './Tabs';
|
||||||
export * from './Toast';
|
export * from './Toast';
|
||||||
|
export * from './VoiceLanguageSelect';
|
||||||
|
|
|
||||||
|
|
@ -353,12 +353,31 @@ export function useAutomationOperations() {
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
// Toggle automation active status
|
// Toggle automation active status
|
||||||
|
// NOTE: Backend PUT expects full AutomationDefinition object including id
|
||||||
const handleAutomationToggleActive = useCallback(async (
|
const handleAutomationToggleActive = useCallback(async (
|
||||||
automationId: string,
|
automationId: string,
|
||||||
currentActive: boolean
|
currentActive: boolean,
|
||||||
|
fullAutomation?: Automation
|
||||||
): Promise<boolean> => {
|
): Promise<boolean> => {
|
||||||
try {
|
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;
|
return true;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error toggling automation active status:', error);
|
console.error('Error toggling automation active status:', error);
|
||||||
|
|
@ -367,21 +386,39 @@ export function useAutomationOperations() {
|
||||||
}, [request]);
|
}, [request]);
|
||||||
|
|
||||||
// Generic inline update handler for FormGeneratorTable
|
// Generic inline update handler for FormGeneratorTable
|
||||||
|
// NOTE: Backend PUT requires full object, so we merge changes with existing row data
|
||||||
const handleInlineUpdate = useCallback(async (
|
const handleInlineUpdate = useCallback(async (
|
||||||
automationId: string,
|
automationId: string,
|
||||||
changes: Partial<Automation>,
|
changes: Partial<Automation>,
|
||||||
existingRow?: any
|
existingRow?: Automation
|
||||||
) => {
|
) => {
|
||||||
if (!existingRow) {
|
if (!existingRow) {
|
||||||
throw new Error('Existing row data required for inline update');
|
throw new Error('Existing row data required for inline update');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await handleAutomationUpdate(automationId, changes);
|
try {
|
||||||
if (!result) {
|
// Merge changes with existing row data and send all required fields
|
||||||
throw new Error(updateError || 'Failed to update');
|
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 };
|
}, [request]);
|
||||||
}, [handleAutomationUpdate, updateError]);
|
|
||||||
|
|
||||||
// Fetch templates
|
// Fetch templates
|
||||||
const fetchTemplates = useCallback(async (): Promise<AutomationTemplate[]> => {
|
const fetchTemplates = useCallback(async (): Promise<AutomationTemplate[]> => {
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export interface AddUserToInstanceRequest {
|
||||||
export interface FeatureInstanceCreate {
|
export interface FeatureInstanceCreate {
|
||||||
featureCode: string;
|
featureCode: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
enabled?: boolean;
|
||||||
copyTemplateRoles?: 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
|
* Delete a feature instance
|
||||||
*/
|
*/
|
||||||
|
|
@ -450,6 +479,7 @@ export function useFeatureAccess() {
|
||||||
fetchFeatures,
|
fetchFeatures,
|
||||||
fetchInstances,
|
fetchInstances,
|
||||||
createInstance,
|
createInstance,
|
||||||
|
updateInstance,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
syncInstanceRoles,
|
syncInstanceRoles,
|
||||||
fetchMyFeatureInstances,
|
fetchMyFeatureInstances,
|
||||||
|
|
|
||||||
|
|
@ -81,10 +81,8 @@
|
||||||
/* Content */
|
/* Content */
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
background: var(--bg-primary, #ffffff);
|
background: var(--bg-primary, #ffffff);
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark Theme */
|
/* Dark Theme */
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,18 @@ const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, us
|
||||||
description: 'Ihre E-Mail-Adresse für Benachrichtigungen',
|
description: 'Ihre E-Mail-Adresse für Benachrichtigungen',
|
||||||
required: true,
|
required: true,
|
||||||
placeholder: 'name@example.com'
|
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) => {
|
const handleProfileSave = useCallback(async (formData: any) => {
|
||||||
if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet');
|
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)
|
// Build full user object for update (backend requires full User model)
|
||||||
const userUpdateData = {
|
const userUpdateData = {
|
||||||
id: currentUser.id,
|
id: currentUser.id,
|
||||||
username: currentUser.username,
|
username: currentUser.username,
|
||||||
email: formData.email || currentUser.email,
|
email: formData.email || currentUser.email,
|
||||||
fullName: formData.fullName || currentUser.fullName,
|
fullName: formData.fullName || currentUser.fullName,
|
||||||
language: currentUser.language || 'de',
|
language: newLanguage,
|
||||||
enabled: currentUser.enabled ?? true,
|
enabled: currentUser.enabled ?? true,
|
||||||
authenticationAuthority: currentUser.authenticationAuthority || 'local'
|
authenticationAuthority: currentUser.authenticationAuthority || 'local'
|
||||||
};
|
};
|
||||||
|
|
@ -184,10 +199,16 @@ export const SettingsPage: React.FC = () => {
|
||||||
setUserDataCache({
|
setUserDataCache({
|
||||||
...cachedUser,
|
...cachedUser,
|
||||||
fullName: updatedUser.fullName || cachedUser.fullName,
|
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
|
// Refetch user data
|
||||||
if (refetchUser) {
|
if (refetchUser) {
|
||||||
await refetchUser();
|
await refetchUser();
|
||||||
|
|
@ -196,7 +217,7 @@ export const SettingsPage: React.FC = () => {
|
||||||
// Dispatch event to notify other components (e.g., sidebar)
|
// Dispatch event to notify other components (e.g., sidebar)
|
||||||
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
||||||
|
|
||||||
}, [currentUser, updateUser, refetchUser]);
|
}, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.settings}>
|
<div className={styles.settings}>
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,7 @@
|
||||||
|
|
||||||
.adminPage {
|
.adminPage {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
height: 100%;
|
min-height: 100%;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageHeader {
|
.pageHeader {
|
||||||
|
|
@ -637,3 +634,121 @@
|
||||||
:global(.spinning) {
|
:global(.spinning) {
|
||||||
animation: spin 1s linear infinite;
|
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 { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||||
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs } from 'react-icons/fa';
|
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import styles from './Admin.module.css';
|
import styles from './Admin.module.css';
|
||||||
|
|
@ -25,6 +25,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
fetchFeatures,
|
fetchFeatures,
|
||||||
fetchInstances,
|
fetchInstances,
|
||||||
createInstance,
|
createInstance,
|
||||||
|
updateInstance,
|
||||||
deleteInstance,
|
deleteInstance,
|
||||||
syncInstanceRoles,
|
syncInstanceRoles,
|
||||||
} = useFeatureAccess();
|
} = useFeatureAccess();
|
||||||
|
|
@ -36,6 +37,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
const [editingInstance, setEditingInstance] = useState<FeatureInstance | null>(null);
|
||||||
const [, setIsSubmitting] = useState(false);
|
const [, setIsSubmitting] = useState(false);
|
||||||
const [syncingInstance, setSyncingInstance] = useState<string | null>(null);
|
const [syncingInstance, setSyncingInstance] = useState<string | null>(null);
|
||||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||||
|
|
@ -78,7 +81,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
|
|
||||||
// Form attributes from backend - merge with dynamic feature options
|
// Form attributes from backend - merge with dynamic feature options
|
||||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||||
const excludedFields = ['id', 'mandateId', 'enabled'];
|
const excludedFields = ['id', 'mandateId'];
|
||||||
const featureOptions = features.map(f => ({
|
const featureOptions = features.map(f => ({
|
||||||
value: f.code,
|
value: f.code,
|
||||||
label: typeof f.label === 'object'
|
label: typeof f.label === 'object'
|
||||||
|
|
@ -92,19 +95,20 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
...attr,
|
...attr,
|
||||||
// Override featureCode: make editable for create and add dynamic options
|
// Override featureCode: make editable for create and add dynamic options
|
||||||
readonly: attr.name === 'featureCode' ? false : attr.readonly,
|
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,
|
options: attr.name === 'featureCode' ? featureOptions : attr.options,
|
||||||
})) as AttributeDefinition[];
|
})) as AttributeDefinition[];
|
||||||
}, [features, backendAttributes]);
|
}, [features, backendAttributes]);
|
||||||
|
|
||||||
// Handle create instance
|
// 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;
|
if (!selectedMandateId) return;
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const result = await createInstance(selectedMandateId, {
|
const result = await createInstance(selectedMandateId, {
|
||||||
featureCode: data.featureCode,
|
featureCode: data.featureCode,
|
||||||
label: data.label,
|
label: data.label,
|
||||||
|
enabled: data.enabled !== false,
|
||||||
copyTemplateRoles: data.copyTemplateRoles !== false
|
copyTemplateRoles: data.copyTemplateRoles !== false
|
||||||
});
|
});
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|
@ -119,16 +123,44 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle delete instance
|
// Handle edit click
|
||||||
const handleDeleteInstance = async (instance: FeatureInstance) => {
|
const handleEditClick = (instance: FeatureInstance) => {
|
||||||
if (!selectedMandateId) return;
|
setEditingInstance(instance);
|
||||||
if (window.confirm(`Möchten Sie die Feature-Instanz "${instance.label}" wirklich löschen? Alle zugehörigen Daten werden gelöscht.`)) {
|
setShowEditModal(true);
|
||||||
const result = await deleteInstance(selectedMandateId, instance.id);
|
};
|
||||||
|
|
||||||
|
// 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) {
|
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 {
|
} 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={[
|
customActions={[
|
||||||
|
{
|
||||||
|
id: 'edit',
|
||||||
|
icon: <FaEdit />,
|
||||||
|
onClick: handleEditClick,
|
||||||
|
title: 'Instanz bearbeiten',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'syncRoles',
|
id: 'syncRoles',
|
||||||
icon: <FaCogs />,
|
icon: <FaCogs />,
|
||||||
onClick: handleSyncRoles,
|
onClick: handleSyncRoles,
|
||||||
title: 'Rollen synchronisieren',
|
title: 'Rollen synchronisieren',
|
||||||
loading: (row: FeatureInstance) => syncingInstance === row.id,
|
loading: (row: FeatureInstance) => syncingInstance === row.id,
|
||||||
|
disabled: (row: FeatureInstance) => !row.enabled,
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
onDelete={handleDeleteInstance}
|
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch: fetchInstances,
|
refetch: fetchInstances,
|
||||||
pagination: instancesPagination,
|
pagination: instancesPagination,
|
||||||
|
|
@ -350,6 +388,49 @@ export const AdminFeatureAccessPage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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 { AdminInvitationsPage } from './AdminInvitationsPage';
|
||||||
export { AdminMandateRolesPage } from './AdminMandateRolesPage';
|
export { AdminMandateRolesPage } from './AdminMandateRolesPage';
|
||||||
export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
|
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}
|
onCancel={handleCloseModal}
|
||||||
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
||||||
cancelButtonText="Abbrechen"
|
cancelButtonText="Abbrechen"
|
||||||
|
instanceId={instanceId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
|
||||||
onCancel={handleCloseModal}
|
onCancel={handleCloseModal}
|
||||||
submitButtonText="Verknüpfung erstellen"
|
submitButtonText="Verknüpfung erstellen"
|
||||||
cancelButtonText="Abbrechen"
|
cancelButtonText="Abbrechen"
|
||||||
|
instanceId={instanceId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -275,6 +275,7 @@ export const TrusteePositionsView: React.FC = () => {
|
||||||
onCancel={handleCloseModal}
|
onCancel={handleCloseModal}
|
||||||
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
|
||||||
cancelButtonText="Abbrechen"
|
cancelButtonText="Abbrechen"
|
||||||
|
instanceId={instanceId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
||||||
import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations';
|
import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations';
|
||||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
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 { useToast } from '../../contexts/ToastContext';
|
||||||
import { useApiRequest } from '../../hooks/useApi';
|
import { useApiRequest } from '../../hooks/useApi';
|
||||||
import { useFeatureStore } from '../../stores/featureStore';
|
import { useFeatureStore } from '../../stores/featureStore';
|
||||||
|
|
@ -54,7 +54,6 @@ export const AutomationsPage: React.FC = () => {
|
||||||
handleAutomationUpdate,
|
handleAutomationUpdate,
|
||||||
handleAutomationDelete,
|
handleAutomationDelete,
|
||||||
handleAutomationExecute,
|
handleAutomationExecute,
|
||||||
handleAutomationToggleActive,
|
|
||||||
handleInlineUpdate,
|
handleInlineUpdate,
|
||||||
fetchTemplates,
|
fetchTemplates,
|
||||||
deletingAutomations,
|
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
|
// Show logs modal
|
||||||
const handleShowLogs = async (automation: Automation) => {
|
const handleShowLogs = async (automation: Automation) => {
|
||||||
const fullAutomation = await fetchAutomationById(automation.id);
|
const fullAutomation = await fetchAutomationById(automation.id);
|
||||||
|
|
@ -582,12 +568,6 @@ export const AutomationsPage: React.FC = () => {
|
||||||
title: 'Ausführen',
|
title: 'Ausführen',
|
||||||
loading: (row: any) => executingAutomations.has(row.id),
|
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',
|
id: 'logs',
|
||||||
icon: <FaList />,
|
icon: <FaList />,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { useResizablePanels } from '../../hooks/useResizablePanels';
|
||||||
import { usePrompts } from '../../hooks/usePrompts';
|
import { usePrompts } from '../../hooks/usePrompts';
|
||||||
import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa';
|
import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa';
|
||||||
import { useToast } from '../../contexts/ToastContext';
|
import { useToast } from '../../contexts/ToastContext';
|
||||||
|
import { useVoiceLanguage, VoiceLanguageSelect } from '../../components/UiComponents';
|
||||||
import api from '../../api';
|
import api from '../../api';
|
||||||
import styles from './PlaygroundPage.module.css';
|
import styles from './PlaygroundPage.module.css';
|
||||||
|
|
||||||
|
|
@ -76,6 +77,9 @@ export const PlaygroundPage: React.FC = () => {
|
||||||
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
|
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
|
||||||
const [audioChunks, setAudioChunks] = useState<Blob[]>([]);
|
const [audioChunks, setAudioChunks] = useState<Blob[]>([]);
|
||||||
|
|
||||||
|
// Voice language selection (defaults to user profile language)
|
||||||
|
const { voiceLanguage, setVoiceLanguage } = useVoiceLanguage();
|
||||||
|
|
||||||
// Prompts dropdown state
|
// Prompts dropdown state
|
||||||
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
|
const [selectedPromptId, setSelectedPromptId] = useState<string>('');
|
||||||
|
|
||||||
|
|
@ -243,11 +247,11 @@ export const PlaygroundPage: React.FC = () => {
|
||||||
try {
|
try {
|
||||||
// Create FormData for speech-to-text API
|
// Create FormData for speech-to-text API
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', audioBlob, 'voice_recording.webm');
|
formData.append('audioFile', audioBlob, 'voice_recording.webm');
|
||||||
formData.append('language', 'de-DE');
|
formData.append('language', voiceLanguage);
|
||||||
|
|
||||||
// Call speech-to-text API
|
// Call speech-to-text API (Google Cloud)
|
||||||
const response = await api.post('/api/ai/speech-to-text', formData, {
|
const response = await api.post('/voice-google/speech-to-text', formData, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' }
|
headers: { 'Content-Type': 'multipart/form-data' }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -799,6 +803,13 @@ export const PlaygroundPage: React.FC = () => {
|
||||||
>
|
>
|
||||||
<FaPlus />
|
<FaPlus />
|
||||||
</button>
|
</button>
|
||||||
|
<VoiceLanguageSelect
|
||||||
|
value={voiceLanguage}
|
||||||
|
onChange={setVoiceLanguage}
|
||||||
|
disabled={isRecording}
|
||||||
|
compact={true}
|
||||||
|
title="Sprache für Spracherkennung"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
className={`${styles.iconButton} ${isRecording ? styles.recording : ''}`}
|
className={`${styles.iconButton} ${isRecording ? styles.recording : ''}`}
|
||||||
onClick={handleVoiceClick}
|
onClick={handleVoiceClick}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,12 @@ export interface InstancePermissions {
|
||||||
fields?: Record<string, Record<string, FieldPermission>>;
|
fields?: Record<string, Record<string, FieldPermission>>;
|
||||||
|
|
||||||
// View-Level (Navigation)
|
// 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>;
|
views: Record<string, boolean>;
|
||||||
|
|
||||||
|
// Admin flag (has admin role in this instance)
|
||||||
|
isAdmin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue