>({
{actionButtons.length > 0 && (
{
@@ -1543,7 +1583,7 @@ export function FormGeneratorTable >({
actionButtonsRefs.current.delete(index);
}
}}
- className={styles.actionButtons}
+ className={`${styles.actionButtons} ${shouldWrapActionButtons ? styles.actionButtonsWrap : ''}`}
>
{/* Standard action buttons (edit, delete, view, copy) */}
{actionButtons.map((actionButton, actionIndex) => {
diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx
index 0c42426..d7d2adf 100644
--- a/src/components/Navigation/MandateNavigation.tsx
+++ b/src/components/Navigation/MandateNavigation.tsx
@@ -24,7 +24,7 @@ import { FEATURE_REGISTRY, getLabel } from '../../types/mandate';
import type { Mandate, MandateFeature, FeatureInstance } from '../../types/mandate';
import {
FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserTag,
- FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube,
+ FaCubes, FaEnvelopeOpenText, FaKey, FaUsersCog, FaCube, FaShieldAlt,
FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaChartBar, FaMicrophone,
FaListAlt, FaCogs
} from 'react-icons/fa';
@@ -59,10 +59,19 @@ function instanceToTreeNode(
const featureConfig = FEATURE_REGISTRY[featureCode];
const views = featureConfig?.views || [];
+ // Check if user has _all views permission (full access)
+ const hasAllViewsPermission = instance.permissions?.views?._all === true;
+
// Filter views based on permissions
+ // A view is visible if:
+ // 1. User has _all views permission, OR
+ // 2. The specific view permission is explicitly true
const visibleViews = views.filter(view => {
const viewCode = `${featureCode}-${view.code}`;
- return instance.permissions?.views?.[viewCode] !== false;
+ if (hasAllViewsPermission) {
+ return true;
+ }
+ return instance.permissions?.views?.[viewCode] === true;
});
// Convert views to children
@@ -192,7 +201,7 @@ export const MandateNavigation: React.FC = () => {
},
{
id: 'workflows-list',
- label: 'Workflows',
+ label: 'Scheduler',
icon: ,
path: '/workflows/list',
},
@@ -302,6 +311,12 @@ export const MandateNavigation: React.FC = () => {
icon: ,
path: '/admin/mandate-roles',
},
+ {
+ id: 'admin-mandate-role-permissions',
+ label: 'Rollen-Berechtigungen',
+ icon: ,
+ path: '/admin/mandate-role-permissions',
+ },
{
id: 'admin-user-mandates',
label: 'Mandanten-Mitglieder',
diff --git a/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.module.css b/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.module.css
new file mode 100644
index 0000000..da4e820
--- /dev/null
+++ b/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.module.css
@@ -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");
+ }
+}
diff --git a/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx b/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx
new file mode 100644
index 0000000..fd38377
--- /dev/null
+++ b/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx
@@ -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 = {
+ '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 = ({
+ 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) => {
+ onChange(e.target.value);
+ };
+
+ return (
+
+
+
+ );
+};
+
+/**
+ * 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(
+ 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;
diff --git a/src/components/UiComponents/VoiceLanguageSelect/index.ts b/src/components/UiComponents/VoiceLanguageSelect/index.ts
new file mode 100644
index 0000000..eecae14
--- /dev/null
+++ b/src/components/UiComponents/VoiceLanguageSelect/index.ts
@@ -0,0 +1,8 @@
+export {
+ VoiceLanguageSelect,
+ useVoiceLanguage,
+ getDefaultVoiceLanguage,
+ voiceLanguages,
+ type VoiceLanguageOption,
+ type VoiceLanguageSelectProps
+} from './VoiceLanguageSelect';
diff --git a/src/components/UiComponents/index.ts b/src/components/UiComponents/index.ts
index 15480f1..c8e9de0 100644
--- a/src/components/UiComponents/index.ts
+++ b/src/components/UiComponents/index.ts
@@ -20,3 +20,4 @@ export * from './AutoScroll';
export * from './Tabs';
export type { TabsProps, Tab } from './Tabs';
export * from './Toast';
+export * from './VoiceLanguageSelect';
diff --git a/src/hooks/useAutomations.ts b/src/hooks/useAutomations.ts
index 7df5a6d..fbf5834 100644
--- a/src/hooks/useAutomations.ts
+++ b/src/hooks/useAutomations.ts
@@ -353,12 +353,31 @@ export function useAutomationOperations() {
}, [request]);
// Toggle automation active status
+ // NOTE: Backend PUT expects full AutomationDefinition object including id
const handleAutomationToggleActive = useCallback(async (
automationId: string,
- currentActive: boolean
+ currentActive: boolean,
+ fullAutomation?: Automation
): Promise => {
try {
- await updateAutomationApi(request, automationId, { active: !currentActive });
+ // Build full update data - backend expects AutomationDefinition with all fields
+ const sourceAutomation = fullAutomation || await fetchAutomationApi(request, automationId);
+
+ // Backend requires id in body to match URL parameter
+ const updateData = {
+ id: automationId, // MUST match URL parameter
+ mandateId: sourceAutomation.mandateId,
+ featureInstanceId: sourceAutomation.featureInstanceId,
+ label: sourceAutomation.label,
+ schedule: sourceAutomation.schedule,
+ template: typeof sourceAutomation.template === 'object'
+ ? JSON.stringify(sourceAutomation.template)
+ : sourceAutomation.template,
+ placeholders: sourceAutomation.placeholders || {},
+ active: !currentActive
+ };
+
+ await updateAutomationApi(request, automationId, updateData as any);
return true;
} catch (error: any) {
console.error('Error toggling automation active status:', error);
@@ -367,21 +386,39 @@ export function useAutomationOperations() {
}, [request]);
// Generic inline update handler for FormGeneratorTable
+ // NOTE: Backend PUT requires full object, so we merge changes with existing row data
const handleInlineUpdate = useCallback(async (
automationId: string,
changes: Partial,
- existingRow?: any
+ existingRow?: Automation
) => {
if (!existingRow) {
throw new Error('Existing row data required for inline update');
}
- const result = await handleAutomationUpdate(automationId, changes);
- if (!result) {
- throw new Error(updateError || 'Failed to update');
+ try {
+ // Merge changes with existing row data and send all required fields
+ const updateData = {
+ id: automationId, // MUST match URL parameter
+ mandateId: existingRow.mandateId,
+ featureInstanceId: existingRow.featureInstanceId,
+ label: existingRow.label,
+ schedule: existingRow.schedule,
+ template: typeof existingRow.template === 'object'
+ ? JSON.stringify(existingRow.template)
+ : existingRow.template,
+ placeholders: existingRow.placeholders || {},
+ // Apply the changes (e.g., active: true/false)
+ ...changes
+ };
+
+ await updateAutomationApi(request, automationId, updateData as any);
+ return { success: true };
+ } catch (error: any) {
+ console.error('Error in inline update:', error);
+ throw new Error(error.message || 'Failed to update');
}
- return { success: true };
- }, [handleAutomationUpdate, updateError]);
+ }, [request]);
// Fetch templates
const fetchTemplates = useCallback(async (): Promise => {
diff --git a/src/hooks/useFeatureAccess.ts b/src/hooks/useFeatureAccess.ts
index 51eb479..9ac69d7 100644
--- a/src/hooks/useFeatureAccess.ts
+++ b/src/hooks/useFeatureAccess.ts
@@ -74,6 +74,7 @@ export interface AddUserToInstanceRequest {
export interface FeatureInstanceCreate {
featureCode: string;
label: string;
+ enabled?: boolean;
copyTemplateRoles?: boolean;
}
@@ -211,6 +212,34 @@ export function useFeatureAccess() {
}
}, []);
+ /**
+ * Update a feature instance (label, enabled)
+ */
+ const updateInstance = useCallback(async (
+ mandateId: string,
+ instanceId: string,
+ data: { label?: string; enabled?: boolean }
+ ): Promise<{ success: boolean; data?: FeatureInstance; error?: string }> => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await api.put(`/api/features/instances/${instanceId}`, data, {
+ headers: {
+ 'X-Mandate-Id': mandateId
+ }
+ });
+ // Update local state
+ setInstances(prev => prev.map(i => i.id === instanceId ? { ...i, ...response.data } : i));
+ return { success: true, data: response.data };
+ } catch (err: any) {
+ const errorMessage = err.response?.data?.detail || err.message || 'Failed to update feature instance';
+ setError(errorMessage);
+ return { success: false, error: errorMessage };
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
/**
* Delete a feature instance
*/
@@ -450,6 +479,7 @@ export function useFeatureAccess() {
fetchFeatures,
fetchInstances,
createInstance,
+ updateInstance,
deleteInstance,
syncInstanceRoles,
fetchMyFeatureInstances,
diff --git a/src/layouts/MainLayout.module.css b/src/layouts/MainLayout.module.css
index 59c3965..f245160 100644
--- a/src/layouts/MainLayout.module.css
+++ b/src/layouts/MainLayout.module.css
@@ -81,10 +81,8 @@
/* Content */
.content {
flex: 1;
- overflow: hidden;
+ overflow: auto;
background: var(--bg-primary, #ffffff);
- display: flex;
- flex-direction: column;
}
/* Dark Theme */
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx
index a8c667b..7aa0818 100644
--- a/src/pages/Settings.tsx
+++ b/src/pages/Settings.tsx
@@ -44,6 +44,18 @@ const ProfileEditModal: React.FC = ({ isOpen, onClose, us
description: 'Ihre E-Mail-Adresse für Benachrichtigungen',
required: true,
placeholder: 'name@example.com'
+ },
+ {
+ name: 'language',
+ type: 'select',
+ label: 'Sprache',
+ description: 'Anzeigesprache der Anwendung',
+ required: true,
+ options: [
+ { value: 'de', label: 'Deutsch' },
+ { value: 'en', label: 'English' },
+ { value: 'fr', label: 'Français' }
+ ]
}
];
@@ -164,13 +176,16 @@ export const SettingsPage: React.FC = () => {
const handleProfileSave = useCallback(async (formData: any) => {
if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet');
+ // Get the new language (from form or current user)
+ const newLanguage = formData.language || currentUser.language || 'de';
+
// Build full user object for update (backend requires full User model)
const userUpdateData = {
id: currentUser.id,
username: currentUser.username,
email: formData.email || currentUser.email,
fullName: formData.fullName || currentUser.fullName,
- language: currentUser.language || 'de',
+ language: newLanguage,
enabled: currentUser.enabled ?? true,
authenticationAuthority: currentUser.authenticationAuthority || 'local'
};
@@ -184,10 +199,16 @@ export const SettingsPage: React.FC = () => {
setUserDataCache({
...cachedUser,
fullName: updatedUser.fullName || cachedUser.fullName,
- email: updatedUser.email || cachedUser.email
+ email: updatedUser.email || cachedUser.email,
+ language: newLanguage
});
}
+ // Update UI language if changed
+ if (newLanguage !== currentLanguage) {
+ setLanguage(newLanguage as 'de' | 'en' | 'fr');
+ }
+
// Refetch user data
if (refetchUser) {
await refetchUser();
@@ -196,7 +217,7 @@ export const SettingsPage: React.FC = () => {
// Dispatch event to notify other components (e.g., sidebar)
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
- }, [currentUser, updateUser, refetchUser]);
+ }, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]);
return (
diff --git a/src/pages/admin/Admin.module.css b/src/pages/admin/Admin.module.css
index 0cc9116..1a422d6 100644
--- a/src/pages/admin/Admin.module.css
+++ b/src/pages/admin/Admin.module.css
@@ -6,10 +6,7 @@
.adminPage {
padding: 1.5rem;
- height: 100%;
- display: flex;
- flex-direction: column;
- overflow: hidden;
+ min-height: 100%;
}
.pageHeader {
@@ -637,3 +634,121 @@
:global(.spinning) {
animation: spin 1s linear infinite;
}
+
+/* ============================================== */
+/* Role Permissions Page Styles */
+/* ============================================== */
+
+/* Scrollable Content Container */
+.scrollableContent {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+/* Filter Bar */
+.filterBar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin-bottom: 1rem;
+ flex-shrink: 0;
+}
+
+.filterGroup {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.filterLabel {
+ font-size: 0.875rem;
+ color: var(--text-secondary);
+ display: flex;
+ align-items: center;
+}
+
+.filterSelect {
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--border-color);
+ border-radius: 6px;
+ background: var(--bg-primary);
+ color: var(--text-primary);
+ font-size: 0.875rem;
+ min-width: 150px;
+}
+
+.filterSelect:focus {
+ outline: none;
+ border-color: var(--primary-color);
+}
+
+.rolesList {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0;
+ padding-bottom: 1rem;
+}
+
+.roleCard {
+ background: var(--bg-secondary, #ffffff);
+ border: 1px solid var(--border-color, #e0e0e0);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.roleHeader {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 1.25rem;
+ cursor: pointer;
+ transition: background-color 0.2s ease;
+}
+
+.roleHeader:hover {
+ background: var(--bg-tertiary, #f8f9fa);
+}
+
+.roleInfo {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.expandIcon {
+ color: var(--text-secondary, #666);
+ font-size: 0.75rem;
+}
+
+.roleLabel {
+ font-weight: 600;
+ color: var(--text-primary, #1a1a1a);
+}
+
+.roleDescription {
+ color: var(--text-secondary, #666);
+ font-size: 0.875rem;
+}
+
+.roleBadges {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.roleContent {
+ padding: 1rem 1.25rem;
+ border-top: 1px solid var(--border-color, #e0e0e0);
+ background: var(--bg-tertiary, #f8f9fa);
+}
+
+.emptyHint {
+ font-size: 0.875rem;
+ color: var(--text-tertiary, #999);
+ margin-top: 0.5rem;
+}
diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx
index 6517db1..ee9546d 100644
--- a/src/pages/admin/AdminFeatureAccessPage.tsx
+++ b/src/pages/admin/AdminFeatureAccessPage.tsx
@@ -10,7 +10,7 @@ import { useFeatureAccess, type FeatureInstance } from '../../hooks/useFeatureAc
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
-import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs } from 'react-icons/fa';
+import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs, FaEdit } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import api from '../../api';
import styles from './Admin.module.css';
@@ -25,6 +25,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
fetchFeatures,
fetchInstances,
createInstance,
+ updateInstance,
deleteInstance,
syncInstanceRoles,
} = useFeatureAccess();
@@ -36,6 +37,8 @@ export const AdminFeatureAccessPage: React.FC = () => {
const [mandates, setMandates] = useState ([]);
const [selectedMandateId, setSelectedMandateId] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false);
+ const [showEditModal, setShowEditModal] = useState(false);
+ const [editingInstance, setEditingInstance] = useState(null);
const [, setIsSubmitting] = useState(false);
const [syncingInstance, setSyncingInstance] = useState(null);
const [backendAttributes, setBackendAttributes] = useState([]);
@@ -78,7 +81,7 @@ export const AdminFeatureAccessPage: React.FC = () => {
// Form attributes from backend - merge with dynamic feature options
const createFields: AttributeDefinition[] = useMemo(() => {
- const excludedFields = ['id', 'mandateId', 'enabled'];
+ const excludedFields = ['id', 'mandateId'];
const featureOptions = features.map(f => ({
value: f.code,
label: typeof f.label === 'object'
@@ -92,19 +95,20 @@ export const AdminFeatureAccessPage: React.FC = () => {
...attr,
// Override featureCode: make editable for create and add dynamic options
readonly: attr.name === 'featureCode' ? false : attr.readonly,
- editable: attr.name === 'featureCode' ? true : attr.editable,
+ editable: attr.name === 'featureCode' || attr.name === 'enabled' ? true : attr.editable,
options: attr.name === 'featureCode' ? featureOptions : attr.options,
})) as AttributeDefinition[];
}, [features, backendAttributes]);
// Handle create instance
- const handleCreateInstance = async (data: { featureCode: string; label: string; copyTemplateRoles?: boolean }) => {
+ const handleCreateInstance = async (data: { featureCode: string; label: string; enabled?: boolean; copyTemplateRoles?: boolean }) => {
if (!selectedMandateId) return;
setIsSubmitting(true);
try {
const result = await createInstance(selectedMandateId, {
featureCode: data.featureCode,
label: data.label,
+ enabled: data.enabled !== false,
copyTemplateRoles: data.copyTemplateRoles !== false
});
if (result.success) {
@@ -119,16 +123,44 @@ export const AdminFeatureAccessPage: React.FC = () => {
}
};
- // Handle delete instance
- const handleDeleteInstance = async (instance: FeatureInstance) => {
- if (!selectedMandateId) return;
- if (window.confirm(`Möchten Sie die Feature-Instanz "${instance.label}" wirklich löschen? Alle zugehörigen Daten werden gelöscht.`)) {
- const result = await deleteInstance(selectedMandateId, instance.id);
+ // Handle edit click
+ const handleEditClick = (instance: FeatureInstance) => {
+ setEditingInstance(instance);
+ setShowEditModal(true);
+ };
+
+ // Handle update instance
+ const handleUpdateInstance = async (data: { label: string; enabled?: boolean }) => {
+ if (!selectedMandateId || !editingInstance) return;
+ setIsSubmitting(true);
+ try {
+ const result = await updateInstance(selectedMandateId, editingInstance.id, {
+ label: data.label,
+ enabled: data.enabled
+ });
if (result.success) {
- showSuccess('Instanz gelöscht', `Die Feature-Instanz "${instance.label}" wurde gelöscht.`);
+ setShowEditModal(false);
+ setEditingInstance(null);
+ fetchInstances(selectedMandateId);
+ showSuccess('Feature-Instanz aktualisiert', `Die Instanz "${data.label}" wurde erfolgreich aktualisiert.`);
} else {
- showError('Fehler', result.error || 'Fehler beim Löschen der Feature-Instanz');
+ showError('Fehler', result.error || 'Fehler beim Aktualisieren der Feature-Instanz');
}
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // Handle delete instance - called by DeleteActionButton with instanceId
+ const handleDeleteInstance = async (instanceId: string): Promise => {
+ if (!selectedMandateId) return false;
+ const result = await deleteInstance(selectedMandateId, instanceId);
+ if (result.success) {
+ showSuccess('Instanz gelöscht', 'Die Feature-Instanz wurde gelöscht.');
+ return true;
+ } else {
+ showError('Fehler', result.error || 'Fehler beim Löschen der Feature-Instanz');
+ return false;
}
};
@@ -296,15 +328,21 @@ export const AdminFeatureAccessPage: React.FC = () => {
}
]}
customActions={[
+ {
+ id: 'edit',
+ icon: ,
+ onClick: handleEditClick,
+ title: 'Instanz bearbeiten',
+ },
{
id: 'syncRoles',
icon: ,
onClick: handleSyncRoles,
title: 'Rollen synchronisieren',
loading: (row: FeatureInstance) => syncingInstance === row.id,
+ disabled: (row: FeatureInstance) => !row.enabled,
}
]}
- onDelete={handleDeleteInstance}
hookData={{
refetch: fetchInstances,
pagination: instancesPagination,
@@ -350,6 +388,49 @@ export const AdminFeatureAccessPage: React.FC = () => {
)}
+
+ {/* Edit Instance Modal */}
+ {showEditModal && editingInstance && (
+ { setShowEditModal(false); setEditingInstance(null); }}>
+ e.stopPropagation()}>
+
+ Feature-Instanz bearbeiten
+
+
+
+ { setShowEditModal(false); setEditingInstance(null); }}
+ submitButtonText="Speichern"
+ cancelButtonText="Abbrechen"
+ />
+
+
+
+ )}
);
};
diff --git a/src/pages/admin/AdminMandateRolePermissionsPage.tsx b/src/pages/admin/AdminMandateRolePermissionsPage.tsx
new file mode 100644
index 0000000..eba7f09
--- /dev/null
+++ b/src/pages/admin/AdminMandateRolePermissionsPage.tsx
@@ -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([]);
+ const [selectedMandateId, setSelectedMandateId] = useState('');
+ const [scopeFilter, setScopeFilter] = useState<'all' | 'mandate' | 'global'>('all');
+ const [expandedRoleId, setExpandedRoleId] = useState(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 (
+
+ System
+
+ );
+ }
+ if (!role.mandateId) {
+ return (
+
+ Global
+
+ );
+ }
+ return (
+
+ Mandant
+
+ );
+ };
+
+ // 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 (
+
+
+ ⚠️
+ Fehler beim Laden: {error}
+
+
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+
+
+ Rollen-Berechtigungen
+
+
+ Verwalten Sie die Zugriffsrechte für Mandanten- und globale Rollen
+
+
+
+
+
+
+
+ {/* Filters */}
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Info Box */}
+
+
+
+ Klicken Sie auf eine Rolle, um deren Berechtigungen (AccessRules) zu bearbeiten.
+ System-Rollen sind schreibgeschützt.
+
+
+
+ {/* Loading State */}
+ {loading && (
+
+ )}
+
+ {/* Empty State */}
+ {!loading && roles.length === 0 && (
+
+
+ Keine Rollen gefunden
+
+ {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.'}
+
+
+ )}
+
+ {/* Roles List */}
+ {!loading && roles.length > 0 && (
+
+ {roles.map(role => (
+
+ {/* Role Header - Clickable to expand */}
+ toggleRole(role.id)}
+ >
+
+
+ {expandedRoleId === role.id ? : }
+
+ {role.roleLabel}
+
+ {getTextValue(role.description)}
+
+
+
+ {getScopeBadge(role)}
+
+
+
+ {/* Expanded Content - AccessRulesEditor */}
+ {expandedRoleId === role.id && (
+
+ )}
+
+ ))}
+
+ )}
+
+ );
+};
+
+export default AdminMandateRolePermissionsPage;
diff --git a/src/pages/admin/index.ts b/src/pages/admin/index.ts
index 90ee496..d0405a3 100644
--- a/src/pages/admin/index.ts
+++ b/src/pages/admin/index.ts
@@ -11,4 +11,5 @@ export { AdminFeatureAccessPage } from './AdminFeatureAccessPage';
export { AdminInvitationsPage } from './AdminInvitationsPage';
export { AdminMandateRolesPage } from './AdminMandateRolesPage';
export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
-export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
\ No newline at end of file
+export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
+export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
\ No newline at end of file
diff --git a/src/pages/views/trustee/TrusteeDocumentsView.tsx b/src/pages/views/trustee/TrusteeDocumentsView.tsx
index c816ceb..263c456 100644
--- a/src/pages/views/trustee/TrusteeDocumentsView.tsx
+++ b/src/pages/views/trustee/TrusteeDocumentsView.tsx
@@ -300,6 +300,7 @@ export const TrusteeDocumentsView: React.FC = () => {
onCancel={handleCloseModal}
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
cancelButtonText="Abbrechen"
+ instanceId={instanceId}
/>
)}
diff --git a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx
index ab804fb..e7960c2 100644
--- a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx
+++ b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx
@@ -220,6 +220,7 @@ export const TrusteePositionDocumentsView: React.FC = () => {
onCancel={handleCloseModal}
submitButtonText="Verknüpfung erstellen"
cancelButtonText="Abbrechen"
+ instanceId={instanceId}
/>
)}
diff --git a/src/pages/views/trustee/TrusteePositionsView.tsx b/src/pages/views/trustee/TrusteePositionsView.tsx
index 083e970..1ce920f 100644
--- a/src/pages/views/trustee/TrusteePositionsView.tsx
+++ b/src/pages/views/trustee/TrusteePositionsView.tsx
@@ -275,6 +275,7 @@ export const TrusteePositionsView: React.FC = () => {
onCancel={handleCloseModal}
submitButtonText={isCreateMode ? 'Erstellen' : 'Speichern'}
cancelButtonText="Abbrechen"
+ instanceId={instanceId}
/>
)}
diff --git a/src/pages/workflows/AutomationsPage.tsx b/src/pages/workflows/AutomationsPage.tsx
index feacdc1..b6efaa7 100644
--- a/src/pages/workflows/AutomationsPage.tsx
+++ b/src/pages/workflows/AutomationsPage.tsx
@@ -9,7 +9,7 @@ import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'
import { useAutomations, useAutomationOperations, AutomationTemplate, Automation } from '../../hooks/useAutomations';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
-import { FaSync, FaRobot, FaRocket, FaPlus, FaPauseCircle, FaPlayCircle, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner } from 'react-icons/fa';
+import { FaSync, FaRobot, FaRocket, FaPlus, FaFileAlt, FaStop, FaList, FaTimes, FaCheck, FaExclamationCircle, FaSpinner } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
import { useApiRequest } from '../../hooks/useApi';
import { useFeatureStore } from '../../stores/featureStore';
@@ -54,7 +54,6 @@ export const AutomationsPage: React.FC = () => {
handleAutomationUpdate,
handleAutomationDelete,
handleAutomationExecute,
- handleAutomationToggleActive,
handleInlineUpdate,
fetchTemplates,
deletingAutomations,
@@ -411,19 +410,6 @@ export const AutomationsPage: React.FC = () => {
});
};
- // Handle toggle active
- const handleToggleActive = async (automation: Automation) => {
- updateOptimistically(automation.id, { active: !automation.active });
-
- const success = await handleAutomationToggleActive(automation.id, automation.active);
- if (success) {
- showSuccess(automation.active ? 'Automatisierung deaktiviert' : 'Automatisierung aktiviert');
- } else {
- updateOptimistically(automation.id, { active: automation.active });
- showError('Fehler beim Ändern des Status');
- }
- };
-
// Show logs modal
const handleShowLogs = async (automation: Automation) => {
const fullAutomation = await fetchAutomationById(automation.id);
@@ -582,12 +568,6 @@ export const AutomationsPage: React.FC = () => {
title: 'Ausführen',
loading: (row: any) => executingAutomations.has(row.id),
},
- {
- id: 'toggleActive',
- icon: (row: any) => row.active ? : ,
- onClick: handleToggleActive,
- title: (row: any) => row.active ? 'Deaktivieren' : 'Aktivieren',
- } as any,
{
id: 'logs',
icon: ,
diff --git a/src/pages/workflows/PlaygroundPage.tsx b/src/pages/workflows/PlaygroundPage.tsx
index 5c45d3b..60dbce3 100644
--- a/src/pages/workflows/PlaygroundPage.tsx
+++ b/src/pages/workflows/PlaygroundPage.tsx
@@ -14,6 +14,7 @@ import { useResizablePanels } from '../../hooks/useResizablePanels';
import { usePrompts } from '../../hooks/usePrompts';
import { FaComment, FaTasks, FaPaperPlane, FaStop, FaFile, FaPlus, FaMicrophone, FaSquare, FaFileAlt } from 'react-icons/fa';
import { useToast } from '../../contexts/ToastContext';
+import { useVoiceLanguage, VoiceLanguageSelect } from '../../components/UiComponents';
import api from '../../api';
import styles from './PlaygroundPage.module.css';
@@ -76,6 +77,9 @@ export const PlaygroundPage: React.FC = () => {
const [mediaRecorder, setMediaRecorder] = useState(null);
const [audioChunks, setAudioChunks] = useState([]);
+ // Voice language selection (defaults to user profile language)
+ const { voiceLanguage, setVoiceLanguage } = useVoiceLanguage();
+
// Prompts dropdown state
const [selectedPromptId, setSelectedPromptId] = useState('');
@@ -243,11 +247,11 @@ export const PlaygroundPage: React.FC = () => {
try {
// Create FormData for speech-to-text API
const formData = new FormData();
- formData.append('file', audioBlob, 'voice_recording.webm');
- formData.append('language', 'de-DE');
+ formData.append('audioFile', audioBlob, 'voice_recording.webm');
+ formData.append('language', voiceLanguage);
- // Call speech-to-text API
- const response = await api.post('/api/ai/speech-to-text', formData, {
+ // Call speech-to-text API (Google Cloud)
+ const response = await api.post('/voice-google/speech-to-text', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
@@ -799,6 +803,13 @@ export const PlaygroundPage: React.FC = () => {
>
+
|