diff --git a/src/App.tsx b/src/App.tsx index c6fecb4..821e327 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,6 +31,7 @@ import { LanguageProvider } from './providers/language/LanguageContext'; import { ToastProvider } from './contexts/ToastContext'; import { WorkflowSelectionProvider } from './contexts/WorkflowSelectionContext'; import { FileProvider } from './contexts/FileContext'; +import { VoiceCatalogProvider } from './contexts/VoiceCatalogContext'; import { MainLayout } from './layouts/MainLayout'; import { FeatureLayout } from './layouts/FeatureLayout'; import { DashboardPage } from './pages/Dashboard'; @@ -72,6 +73,7 @@ function App() { + @@ -231,6 +233,7 @@ function App() { + diff --git a/src/api/authApi.ts b/src/api/authApi.ts index 30ce6bb..95ea26c 100644 --- a/src/api/authApi.ts +++ b/src/api/authApi.ts @@ -101,6 +101,7 @@ export interface AuthUser { roleLabels?: string[]; authenticationAuthority: string; isSysAdmin?: boolean; + isPlatformAdmin?: boolean; [key: string]: any; } diff --git a/src/api/featuresApi.ts b/src/api/featuresApi.ts index 779dc6c..be2818b 100644 --- a/src/api/featuresApi.ts +++ b/src/api/featuresApi.ts @@ -14,6 +14,7 @@ import type { InstancePermissions, AccessLevel, } from '../types/mandate'; +import { mandateDisplayLabel } from '../utils/mandateDisplayUtils'; // ============================================================================= // MOCK DATA (Temporär bis Backend bereit) @@ -71,7 +72,8 @@ const MOCK_RESPONSE: FeaturesMyResponse = { mandates: [ { id: 'mand-soha', - name: 'Soha Treuhand', + name: 'soha-treuhand', + label: 'Soha Treuhand', code: 'soha', features: [ { @@ -119,7 +121,8 @@ const MOCK_RESPONSE: FeaturesMyResponse = { }, { id: 'mand-swiss', - name: 'SwissTreu', + name: 'swisstreu', + label: 'SwissTreu', code: 'swisstreu', features: [ { @@ -189,7 +192,7 @@ export async function fetchMyFeatures(): Promise { if (feature.code === 'chatbot') { console.log('🔍 [DEBUG] featuresApi: Found chatbot feature', { mandateId: mandate.id, - mandateName: mandate.label || mandate.name, + mandateName: mandateDisplayLabel(mandate), featureCode: feature.code, instanceCount: feature.instances.length, }); diff --git a/src/api/fileApi.ts b/src/api/fileApi.ts index 4fd3dfe..18dc47e 100644 --- a/src/api/fileApi.ts +++ b/src/api/fileApi.ts @@ -212,6 +212,8 @@ export interface FolderInfo { mandateId?: string; featureInstanceId?: string; createdAt?: number; + scope?: string; + neutralize?: boolean; } export async function fetchFolders( diff --git a/src/api/mandateApi.ts b/src/api/mandateApi.ts index 9f4076d..38bf41c 100644 --- a/src/api/mandateApi.ts +++ b/src/api/mandateApi.ts @@ -4,11 +4,40 @@ import { ApiRequestOptions } from '../hooks/useApi'; // TYPES & INTERFACES // ============================================================================ +/** + * Mandate (Mandant) — represents one tenant in PowerOn PORTA. + * + * Field semantics (must stay in sync with the backend `Mandate` Pydantic model): + * - `id` — UUID, immutable. + * - `name` — Kurzzeichen / slug. Globally unique, lowercase [a-z0-9] with + * hyphen-separated segments (length 2–32). Used for audit/tracking + * and stable references. Only PlatformAdmin can change it after + * creation. + * - `label` — Voller Name. Mandatory, human-readable display name shown in the + * UI. Freely changeable by a Mandate-Admin. + */ export interface Mandate { id: string; + name: string; + label: string; + enabled?: boolean; + isSystem?: boolean; + deletedAt?: number | null; [key: string]: any; // Allow additional properties from backend } +/** Payload for creating a mandate. `label` is required, `name` is optional. */ +export interface MandateCreateData { + label: string; + name?: string; + enabled?: boolean; + [key: string]: any; +} + +/** + * Payload for updating a mandate. Only PlatformAdmin may change `name`; + * Mandate-Admin can update `label` and other UI fields. + */ export type MandateUpdateData = Partial>; export interface PaginationParams { @@ -112,7 +141,7 @@ export async function updateMandate( */ export async function createMandate( request: ApiRequestFunction, - mandateData: Partial + mandateData: MandateCreateData | Partial ): Promise { return await request({ url: '/api/mandates/', diff --git a/src/api/teamsbotApi.ts b/src/api/teamsbotApi.ts index 607d998..6942d7a 100644 --- a/src/api/teamsbotApi.ts +++ b/src/api/teamsbotApi.ts @@ -102,18 +102,10 @@ export interface ConfigUpdateRequest { debugMode?: boolean; } -// Voice/Language Types (from Google TTS API) -export interface VoiceLanguage { - code: string; - name: string; -} - -export interface VoiceOption { - name: string; - languageCodes: string[]; - ssmlGender: string; - naturalSampleRateHertz: number; -} +// Voice option type re-exported from the central voice catalog API. +// The legacy teamsbot-specific {code,name} language type is gone — consumers +// should use VoiceLanguage from voiceCatalogApi (catalog SSOT). +export type { VoiceOption } from './voiceCatalogApi'; // Auth Detection Test Types export interface StepScreenshot { @@ -313,25 +305,19 @@ export async function testVoice( } /** - * Fetch available TTS languages from Google Cloud. - * Returns array of language codes (e.g. ["de-DE", "en-US", ...]) + * Fetch the curated voice/language catalog (single source of truth). + * Re-exports the central voiceCatalogApi.fetchVoiceCatalog so legacy + * teamsbot consumers stay on one import surface. */ -export async function fetchLanguages(): Promise { - try { - const response = await api.get('/voice-google/languages'); - return response.data?.languages || []; - } catch { - return []; - } -} +export { fetchVoiceCatalog as fetchLanguages } from './voiceCatalogApi'; /** * Fetch available TTS voices for a language from Google Cloud. */ export async function fetchVoices(languageCode: string): Promise { try { - const response = await api.get('/voice-google/voices', { - params: { languageCode }, + const response = await api.get('/api/voice/voices', { + params: { language: languageCode }, }); return response.data?.voices || []; } catch { diff --git a/src/api/userApi.ts b/src/api/userApi.ts index 80207c0..d16bf38 100644 --- a/src/api/userApi.ts +++ b/src/api/userApi.ts @@ -13,7 +13,8 @@ export interface User { enabled: boolean; roleLabels?: string[]; // Array of role labels from backend (e.g., ["user"]) authenticationAuthority: string; - isSysAdmin?: boolean; // System-Administrator Flag + isSysAdmin?: boolean; // Infrastructure/System Operator (RBAC bypass) + isPlatformAdmin?: boolean; // Cross-Mandate Governance (no RBAC bypass) // mandateId ist nicht mehr Teil des User-Objekts (Multi-Tenant-Konzept) // Der Mandant-Kontext wird über Feature-Instanzen bestimmt [key: string]: any; // Allow additional properties diff --git a/src/api/voiceCatalogApi.ts b/src/api/voiceCatalogApi.ts new file mode 100644 index 0000000..1bdf731 --- /dev/null +++ b/src/api/voiceCatalogApi.ts @@ -0,0 +1,47 @@ +/** + * Voice / Language Catalog API. + * + * Single source of truth for every voice-language picker, default-voice + * lookup, and ISO ⇄ BCP-47 mapping in the frontend. Mirrors + * gateway/modules/shared/voiceCatalog.py 1:1. + * + * Hard-coded language lists or ad-hoc maps in components are forbidden — + * consume `useVoiceCatalog()` instead. + */ + +import api from '../api'; + +export interface VoiceLanguage { + bcp47: string; + iso: string; + label: string; + flag: string; + defaultVoice: string | null; +} + +export interface VoiceOption { + name: string; + languageCodes: string[]; + ssmlGender: string; + naturalSampleRateHertz: number; +} + +interface CatalogResponse { + languages: VoiceLanguage[]; +} + +interface VoicesResponse { + voices: VoiceOption[]; +} + +export async function fetchVoiceCatalog(): Promise { + const response = await api.get('/api/voice/languages'); + return response.data?.languages ?? []; +} + +export async function fetchVoicesForLanguage(bcp47: string): Promise { + const response = await api.get('/api/voice/voices', { + params: { language: bcp47 }, + }); + return response.data?.voices ?? []; +} diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css index b2f5605..5f1e938 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.module.css +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.module.css @@ -277,7 +277,7 @@ overflow: hidden; border-radius: 8px; /* Infinite grid: on viewport, moves with pan/zoom via inline style */ - background-image: radial-gradient(circle, var(--border-color, #e0e0e0) 1px, transparent 1px); + background-image: radial-gradient(circle, var(--canvas-grid, var(--border-color, #e0e0e0)) 1px, transparent 1px); background-repeat: repeat; } diff --git a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx index 37a1fdb..1438279 100644 --- a/src/components/FlowEditor/editor/Automation2FlowEditor.tsx +++ b/src/components/FlowEditor/editor/Automation2FlowEditor.tsx @@ -48,6 +48,7 @@ import { Automation2DataFlowProvider } from '../context/Automation2DataFlowConte import { usePrompt } from '../../../hooks/usePrompt'; import { EditorChatPanel } from './EditorChatPanel'; import type { PendingFile, EditorDataSource, EditorFeatureDataSource } from './EditorChatPanel'; +import { EditorWorkflowChatList } from './EditorWorkflowChatList'; import { RunTracingPanel } from './RunTracingPanel'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; @@ -114,7 +115,8 @@ export const Automation2FlowEditor: React.FC = ({ in const [tracingRunId, setTracingRunId] = useState(null); const [tracingNodeStatuses, setTracingNodeStatuses] = useState>({}); const [rightTab, setRightTab] = useState<'nodes' | 'tracing'>('nodes'); - const [udbTab, setUdbTab] = useState('chats'); + type LeftTab = UdbTab | 'ai'; + const [udbTab, setUdbTab] = useState('ai'); const udbContext: UdbContext = useMemo(() => ({ instanceId, @@ -649,19 +651,30 @@ export const Automation2FlowEditor: React.FC = ({ in {leftPanelOpen && (<>
- {(['chats', 'files', 'sources'] as const).map((tab) => ( + {(['ai', 'chats', 'files', 'sources'] as const).map((tab) => ( ))}
-
- {udbTab === 'chats' ? ( +
+ {/* + KI-Panel bleibt gemountet, damit der Chatverlauf beim Tab-Wechsel + (Chats / Dateien / Quellen) erhalten bleibt. Nur per CSS umblenden. + `key={currentWorkflowId}` setzt den Verlauf sauber zurück, wenn der + Nutzer einen anderen Workflow wählt. + */} +
{ if (currentWorkflowId) handleLoad(currentWorkflowId); }} @@ -670,11 +683,21 @@ export const Automation2FlowEditor: React.FC = ({ in dataSources={dataSources} featureDataSources={featureDataSources} /> - ) : ( +
+ {udbTab === 'chats' && ( + + )} + {(udbTab === 'files' || udbTab === 'sources') && ( setUdbTab(tab as LeftTab)} hideTabs={['chats']} onFileSelect={onFileSelect} onSourcesChanged={onSourcesChanged} diff --git a/src/components/FlowEditor/editor/EditorChatPanel.tsx b/src/components/FlowEditor/editor/EditorChatPanel.tsx index 7afb10e..84064ff 100644 --- a/src/components/FlowEditor/editor/EditorChatPanel.tsx +++ b/src/components/FlowEditor/editor/EditorChatPanel.tsx @@ -7,11 +7,25 @@ * - Files: drag & drop from FolderTree onto input area, or click in UDB * - Data Sources: 🔗 picker button next to input (toggle-select from active sources) */ -import React, { useState, useCallback, useRef } from 'react'; +import React, { useState, useCallback, useEffect, useRef } from 'react'; import { startSseStream } from '../../../utils/sseClient'; import { ChatMessageList } from '../../Chat'; import type { ChatMessage } from '../../Chat'; import { getPageIcon } from '../../../config/pageRegistry'; +import api from '../../../api'; + +interface PersistedEditorChatMessage { + id: string; + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp?: number; + sequenceNr?: number; +} + +interface PersistedEditorChatResponse { + chatWorkflowId: string | null; + messages: PersistedEditorChatMessage[]; +} import { useLanguage } from '../../../providers/language/LanguageContext'; @@ -58,16 +72,55 @@ export const EditorChatPanel: React.FC = ({ instanceId, }) => { const { t } = useLanguage(); const [messages, setMessages] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); const [loading, setLoading] = useState(false); const [prompt, setPrompt] = useState(''); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState([]); const [showSourcePicker, setShowSourcePicker] = useState(false); const [treeDropOver, setTreeDropOver] = useState(false); + const [stopping, setStopping] = useState(false); const abortRef = useRef<(() => void) | null>(null); + const assistantIdRef = useRef(null); const textareaRef = useRef(null); const pickerRef = useRef(null); + // Load persisted chat history from the backend whenever the workflow changes. + // The chat is stored in `ChatWorkflow.linkedWorkflowId == workflowId` and is + // returned by `GET /api/workflows/{instanceId}/{workflowId}/chat/messages`. + // For an unsaved workflow (workflowId == null) we just clear the panel. + useEffect(() => { + if (!workflowId) { + setMessages([]); + return; + } + let cancelled = false; + const _loadHistory = async () => { + setHistoryLoading(true); + try { + const res = await api.get( + `/api/workflows/${instanceId}/${workflowId}/chat/messages`, + ); + if (cancelled) return; + const persisted = (res.data?.messages || []).map((m): ChatMessage => ({ + id: m.id || `persisted-${++_msgCounter}`, + role: m.role, + content: m.content, + timestamp: m.timestamp ? Math.round(Number(m.timestamp) * 1000) : Date.now(), + })); + setMessages(persisted); + } catch (err) { + if (cancelled) return; + console.warn('EditorChatPanel: failed to load chat history', err); + setMessages([]); + } finally { + if (!cancelled) setHistoryLoading(false); + } + }; + _loadHistory(); + return () => { cancelled = true; }; + }, [instanceId, workflowId]); + const _toggleDataSource = useCallback((dsId: string) => { setAttachedDataSourceIds(prev => prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId], @@ -85,9 +138,10 @@ export const EditorChatPanel: React.FC = ({ instanceId, if (!workflowId || loading || !trimmed) return; const fileIds = pendingFiles.map(f => f.fileId); + // Note: conversationHistory is no longer sent — the backend loads it + // server-side from the persisted ChatWorkflow (linkedWorkflowId). const body: Record = { message: trimmed, - conversationHistory: messages.map(m => ({ role: m.role, message: m.content })), userLanguage: navigator.language?.slice(0, 2) || 'de', }; if (fileIds.length > 0) body.fileIds = fileIds; @@ -106,11 +160,13 @@ export const EditorChatPanel: React.FC = ({ instanceId, setLoading(true); const assistantId = `asst-${++_msgCounter}`; + assistantIdRef.current = assistantId; let accumulated = ''; setMessages(prev => [...prev, { id: assistantId, role: 'assistant', content: '', timestamp: Date.now() }]); + const baseURL = api.defaults.baseURL || ''; const cleanup = startSseStream({ - url: `/api/workflows/${instanceId}/${workflowId}/chat/stream`, + url: `${baseURL}/api/workflows/${instanceId}/${workflowId}/chat/stream`, body, handlers: { onChunk: (event) => { @@ -142,17 +198,40 @@ export const EditorChatPanel: React.FC = ({ instanceId, } setLoading(false); }, - onStopped: () => setLoading(false), + onStopped: () => { + setMessages(prev => prev.map(m => m.id === assistantId + ? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Gestoppt.')}_` } + : m)); + setLoading(false); + setStopping(false); + }, }, onConnectionError: (err) => { setMessages(prev => prev.map(m => m.id === assistantId ? { ...m, content: `${t('Fehler')}: ${err.message}` } : m)); setLoading(false); + setStopping(false); }, - onStreamEnd: () => setLoading(false), + onStreamEnd: () => { setLoading(false); setStopping(false); }, }); abortRef.current = cleanup; - }, [prompt, loading, workflowId, instanceId, messages, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]); + }, [prompt, loading, workflowId, instanceId, onGraphUpdated, pendingFiles, attachedDataSourceIds, attachedFeatureDataSourceIds, t]); + + const _handleStop = useCallback(async () => { + if (!workflowId || stopping) return; + setStopping(true); + const assistantId = assistantIdRef.current; + if (assistantId) { + setMessages(prev => prev.map(m => m.id === assistantId + ? { ...m, content: (m.content ? m.content + '\n\n' : '') + `_${t('Stoppen…')}_` } + : m)); + } + try { + await api.post(`/api/workflows/${instanceId}/${workflowId}/chat/stop`); + } catch { + } + abortRef.current?.(); + }, [workflowId, instanceId, stopping, t]); const _handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -188,8 +267,8 @@ export const EditorChatPanel: React.FC = ({ instanceId,
{/* Pending files (from UDB drag/click) */} @@ -391,10 +470,12 @@ export const EditorChatPanel: React.FC = ({ instanceId, )} {loading ? ( - + background: stopping ? '#9e9e9e' : '#f44336', color: '#fff', + cursor: stopping ? 'wait' : 'pointer', fontWeight: 600, fontSize: 12, + opacity: stopping ? 0.7 : 1, + }}>{stopping ? t('Stoppen…') : t('Stopp')} ) : ( +
+ +
+ {filtered.length === 0 ? ( +
+ {workflows.length === 0 + ? t('Noch keine Workflows. Klicken Sie auf „+ Neu", um einen Workflow-Chat zu starten.') + : t('Keine Treffer.')} +
+ ) : ( + filtered.map((wf) => { + const isActive = wf.id === currentWorkflowId; + const ts = wf.lastStartedAt || wf.createdAt; + return ( +
onSelect(wf.id)} + style={{ + padding: '10px 12px', cursor: 'pointer', + borderBottom: '1px solid var(--border-color-soft, #f0f0f0)', + background: isActive ? 'rgba(242, 88, 67, 0.08)' : 'transparent', + borderLeft: isActive ? '3px solid var(--primary-color, #F25843)' : '3px solid transparent', + }} + onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = '#f7f7f7'; }} + onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent'; }} + > +
+ + {wf.label || t('(unbenannt)')} + + {wf.isRunning && ( + + )} +
+
+ {typeof wf.runCount === 'number' && ( + {wf.runCount} {wf.runCount === 1 ? t('Lauf') : t('Läufe')} + )} + {ts ? · {_formatRelative(ts)} : null} +
+
+ ); + }) + )} +
+
+ ); +}; + +export default EditorWorkflowChatList; diff --git a/src/components/FolderTree/FolderTree.module.css b/src/components/FolderTree/FolderTree.module.css index b13f1fe..d0db9f7 100644 --- a/src/components/FolderTree/FolderTree.module.css +++ b/src/components/FolderTree/FolderTree.module.css @@ -86,10 +86,21 @@ min-width: 0; } +/* Right zone: contains dynamic on-hover actions + always-visible stable trio. + * The stable trio (chat / scope / neutralize) sits at the right edge in a + * fixed slot order so icons never jump. Dynamic actions appear on hover + * to the left of the trio without displacing it. */ +.rightZone { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + flex-shrink: 0; +} + .actions { display: none; gap: 2px; - margin-left: auto; flex-shrink: 0; } @@ -97,6 +108,26 @@ display: flex; } +.stableActions { + display: flex; + gap: 2px; + flex-shrink: 0; + align-items: center; +} + +.iconSlot { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 20px; + flex-shrink: 0; +} + +.iconSlot.placeholder { + visibility: hidden; +} + .actionBtn { background: none; border: none; @@ -148,25 +179,6 @@ flex-shrink: 0; } -.scopeIcons { - display: flex; - gap: 2px; - flex-shrink: 0; - align-items: center; -} - -.rightZone { - display: flex; - align-items: center; - gap: 4px; - margin-left: auto; - flex-shrink: 0; -} - -.rightZone .actions { - margin-left: 0; -} - .rootActions { display: flex; gap: 2px; diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx index 2e2194c..289b404 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -30,6 +30,7 @@ export interface FolderNode { isReadonly?: boolean; icon?: string; neutralize?: boolean; + scope?: string; } export interface FileNode { @@ -76,6 +77,7 @@ export interface FolderTreeProps { onDownloadFolder?: (folderId: string, folderName: string) => Promise; onScopeChange?: (fileId: string, newScope: string) => void; onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; + onFolderScopeChange?: (folderId: string, newScope: string) => void; onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void; onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; } @@ -186,6 +188,78 @@ interface SelectionCtx { onSendToChat?: (items: Array<{ id: string; type: 'file' | 'folder'; name: string }>) => void; } +/* ── Stable trio (chat | scope | neutralize) ────────────────────────────── + * Always rendered in this order, always at the right edge of the row. + * Each slot has a fixed width so missing actions render an invisible + * placeholder — icons never jump position between rows. */ + +interface StableTrioProps { + scope?: string; + neutralize?: boolean; + scopeLabels: Record; + onChat?: () => void; + onScopeChange?: (newScope: string) => void; + onNeutralizeToggle?: (newValue: boolean) => void; + chatTitle: string; +} + +function _StableTrio({ + scope, neutralize, + scopeLabels, + onChat, onScopeChange, onNeutralizeToggle, + chatTitle, +}: StableTrioProps) { + const { t } = useLanguage(); + const _cycleScope = (current: string | undefined) => { + const idx = _SCOPE_CYCLE.indexOf(current ?? 'personal'); + return _SCOPE_CYCLE[(idx + 1) % _SCOPE_CYCLE.length]; + }; + + return ( + + {/* Slot 1: Chat */} + {onChat ? ( + + ) : ( + + )} + {/* Slot 2: Scope */} + {onScopeChange && scope != null ? ( + + ) : ( + + )} + {/* Slot 3: Neutralize */} + {onNeutralizeToggle ? ( + + ) : ( + + )} + + ); +} + /* ── File node (leaf) ─────────────────────────────────────────────────── */ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { @@ -265,12 +339,12 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { )} {!renaming && ( + {file.fileSize != null && ( + + {(file.fileSize / 1024).toFixed(0)}K + + )} - {sel.onSendToChat && ( - - )} {sel.onRenameFile && !multiSelected && ( - - - )} + <_StableTrio + scope={file.scope} + neutralize={file.neutralize} + scopeLabels={scopeLabels} + onChat={sel.onSendToChat ? () => sel.onSendToChat!([{ id: file.id, type: 'file', name: file.fileName }]) : undefined} + onScopeChange={sel.onScopeChange ? (next) => sel.onScopeChange!(file.id, next) : undefined} + onNeutralizeToggle={sel.onNeutralizeToggle ? (next) => sel.onNeutralizeToggle!(file.id, next) : undefined} + chatTitle={t('In Chat senden')} + /> )}
@@ -360,6 +409,7 @@ interface TreeNodeProps { onMoveFile?: (fileId: string, targetFolderId: string | null) => Promise; onMoveFiles?: (fileIds: string[], targetFolderId: string | null) => Promise; onDownloadFolder?: (folderId: string, folderName: string) => Promise; + onFolderScopeChange?: (folderId: string, newScope: string) => void; onFolderNeutralizeToggle?: (folderId: string, newValue: boolean) => void; } @@ -368,9 +418,15 @@ function _TreeNode({ promptFolderName, onToggle, onSelect, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, - onDownloadFolder, onFolderNeutralizeToggle, + onDownloadFolder, onFolderScopeChange, onFolderNeutralizeToggle, }: TreeNodeProps) { const { t } = useLanguage(); + const scopeLabels = useMemo((): Record => ({ + personal: t('Persönlich'), + featureInstance: t('Instanz'), + mandate: t('Mandant'), + global: t('Global'), + }), [t]); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(node.name); const [dropOver, setDropOver] = useState(false); @@ -523,57 +579,53 @@ function _TreeNode({ {node.name} )} {!isProtected && ( - - {sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - )} - {!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - )} - {onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - )} - {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - )} - {!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( - - )} - {isMultiSelected && sel.selectedItemIds.size > 1 ? ( - <> - {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( - - )} - {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( - - )} - - ) : !notEditable && onDeleteFolder && ( - - )} + + + {!notEditable && onDownloadFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} + {onCreateFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} + {!notEditable && onRenameFolder && !(isMultiSelected && sel.selectedItemIds.size > 1) && ( + + )} + {isMultiSelected && sel.selectedItemIds.size > 1 ? ( + <> + {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( + + )} + {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( + + )} + + ) : !notEditable && onDeleteFolder && ( + + )} + + <_StableTrio + scope={node.scope} + neutralize={node.neutralize} + scopeLabels={scopeLabels} + onChat={(sel.onSendToChat && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? () => sel.onSendToChat!([{ id: node.id, type: 'folder', name: node.name }]) : undefined} + onScopeChange={(onFolderScopeChange && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderScopeChange(node.id, next) : undefined} + onNeutralizeToggle={(onFolderNeutralizeToggle && !(isMultiSelected && sel.selectedItemIds.size > 1)) ? (next) => onFolderNeutralizeToggle(node.id, next) : undefined} + chatTitle={t('In Chat senden')} + /> )}
@@ -600,6 +652,7 @@ function _TreeNode({ onMoveFile={onMoveFile} onMoveFiles={onMoveFiles} onDownloadFolder={onDownloadFolder} + onFolderScopeChange={onFolderScopeChange} onFolderNeutralizeToggle={onFolderNeutralizeToggle} /> ))} @@ -620,7 +673,7 @@ export default function FolderTree({ expandedIds: externalExpandedIds, onToggleExpand, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, - onScopeChange, onNeutralizeToggle, onFolderNeutralizeToggle, onSendToChat, + onScopeChange, onNeutralizeToggle, onFolderScopeChange, onFolderNeutralizeToggle, onSendToChat, }: FolderTreeProps) { const { t } = useLanguage(); @@ -848,6 +901,7 @@ export default function FolderTree({ onMoveFile={onMoveFile} onMoveFiles={onMoveFiles} onDownloadFolder={onDownloadFolder} + onFolderScopeChange={onFolderScopeChange} onFolderNeutralizeToggle={onFolderNeutralizeToggle} /> ))} diff --git a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx index 2a48ac7..b60d672 100644 --- a/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx +++ b/src/components/FormGenerator/FormGeneratorForm/FormGeneratorForm.tsx @@ -15,6 +15,21 @@ import { getDefaultValueForType } from '../../../utils/attributeTypeMapper'; import type { AttributeType } from '../../../utils/attributeTypeMapper'; +import { + SLUG_HINT, + maskSlugInput, + slugify, + validateSlug, +} from '../../../utils/slugUtils'; + +const _isSlugType = (attrType: AttributeType | undefined): boolean => attrType === 'slug'; + +/** + * Default source field used to auto-derive a slug in `create` mode. A specific + * attribute can override this by setting `slugSource` in its definition + * (json_schema_extra.slug_source on the backend). + */ +const _DEFAULT_SLUG_SOURCE_FIELD = 'label'; const isTextMultilingual = (value: any): boolean => { if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) { @@ -370,33 +385,58 @@ export function FormGeneratorForm>({ })); }; + // Tracks slug fields that have been touched manually so we don't override them + // when the user keeps editing the source label afterwards. + const slugFieldsManuallyEdited = useRef>(new Set()); + // Handle field value changes // For timestamp fields: Convert datetime-local string to Unix timestamp (float in seconds) const handleFieldChange = (fieldName: string, value: any, fieldType?: AttributeType) => { let processedValue = value; - - // If field type is timestamp, convert datetime-local string to Unix timestamp + if (fieldType === 'timestamp' && typeof value === 'string' && value) { const date = new Date(value); if (!isNaN(date.getTime())) { - // Convert to Unix timestamp in seconds (float) processedValue = date.getTime() / 1000; } } - - setFormData(prev => ({ - ...prev, - [fieldName]: processedValue - })); - // Clear error for this field when user starts typing - if (errors[fieldName]) { - setErrors(prev => { - const newErrors = { ...prev }; - delete newErrors[fieldName]; - return newErrors; - }); + if (_isSlugType(fieldType)) { + processedValue = maskSlugInput(String(value ?? '')); + slugFieldsManuallyEdited.current.add(fieldName); } + + const autoFilledSlugFields = new Set(); + + setFormData(prev => { + const next: any = { ...prev, [fieldName]: processedValue }; + + // Generic auto-suggest: any slug attribute can declare its source field + // via attr.slugSource (default: 'label'). When that source changes in + // create mode and the slug is still untouched, derive a suggestion. + if (mode === 'create' && !_isSlugType(fieldType)) { + const attrs = attributes ?? []; + for (const a of attrs) { + if (!_isSlugType(a.type as AttributeType)) continue; + const source = (a as any).slugSource || _DEFAULT_SLUG_SOURCE_FIELD; + if (source !== fieldName) continue; + if (slugFieldsManuallyEdited.current.has(a.name)) continue; + const sourceStr = typeof processedValue === 'string' ? processedValue : ''; + if (sourceStr.trim().length === 0) continue; + next[a.name] = slugify(sourceStr); + autoFilledSlugFields.add(a.name); + } + } + return next; + }); + + setErrors(prev => { + if (!prev[fieldName] && autoFilledSlugFields.size === 0) return prev; + const newErrors = { ...prev }; + if (newErrors[fieldName]) delete newErrors[fieldName]; + autoFilledSlugFields.forEach(n => delete newErrors[n]); + return newErrors; + }); }; // Convert Unix timestamp (seconds) to datetime-local input format @@ -509,6 +549,14 @@ export function FormGeneratorForm>({ } } + if (_isSlugType(attr.type as AttributeType)) { + const slugErr = validateSlug(String(value)); + if (slugErr) { + newErrors[attr.name] = t(slugErr); + return; + } + } + // Select/Multiselect option validation if (isSelectType(attr.type)) { const options = normalizeOptions(attr); @@ -1019,6 +1067,38 @@ export function FormGeneratorForm>({ ); } + if (_isSlugType(attr.type as AttributeType)) { + const slugValue = typeof value === 'string' ? value : (value == null ? '' : String(value)); + return ( +
+ handleFieldChange(attr.name, e.target.value, 'slug')} + onFocus={() => handleFieldFocus(attr.name, true)} + onBlur={() => handleFieldFocus(attr.name, false)} + className={`${styles.fieldInput} ${hasError ? styles.fieldError : ''}`} + /> + + + {t(SLUG_HINT)} + + {hasError && {hasError}} +
+ ); + } + // Default input field (text, email, date, time, url, password, number, integer, float, timestamp) const inputType = attributeTypeToInputType(attr.type); diff --git a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx index 82858e6..732330b 100644 --- a/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx +++ b/src/components/FormGenerator/FormGeneratorTable/FormGeneratorTable.tsx @@ -1553,62 +1553,95 @@ export function FormGeneratorTable>({ return isCheckboxType(column.type); }, []); - // Handle inline toggle for boolean fields + // Always-current snapshot of `data` so a queued toggle reads the freshly + // refetched row (server truth from the previous PUT+refetch) instead of the + // stale `row` captured by React at render time. + const dataRef = useRef(data); + useEffect(() => { dataRef.current = data; }, [data]); + + // Per-row update queue: every toggle on the same row awaits the previous + // one so PUT + refetch are strictly serialized. Combined with a refetch + // after every PUT, this guarantees that the next queued PUT merges its + // payload from confirmed server state — never from an unconfirmed UI guess. + const inlineUpdateQueueRef = useRef>>(new Map()); + + // Handle inline toggle for boolean fields. + // + // Design contract (no optimistic UI): + // 1. The cell shows a spinner immediately on click. + // 2. We send the PUT. + // 3. We always trigger a refetch — the table only ever displays values + // that the backend has returned. + // 4. The cell re-renders from the refetched server data. + // + // We deliberately do NOT call ``hookData.updateOptimistically`` here: + // flipping the cell client-side before the backend confirmed leads to + // (a) misleading UX (a click that silently reverts on error) and + // (b) clobber-PUTs when the user toggles a sibling cell while the previous + // change is still in flight (its payload would be merged from the + // unconfirmed local state). const handleInlineToggle = useCallback(async (row: T, column: ColumnConfig, currentValue: boolean) => { if (!canInlineEdit || !isInlineEditableColumn(column)) return; - + const rowId = row[idField]; const cellKey = `${rowId}-${column.key}`; - - // Check if update function is available (either from prop or hookData) + const updateFn = onInlineUpdate || hookData?.handleInlineUpdate; if (!updateFn) { - // Silent return - inline editing is optional, no warning needed + // Inline editing is optional — silently noop when no handler is wired. return; } - - // Mark cell as updating + setUpdatingCells(prev => new Set(prev).add(cellKey)); - const newValue = !currentValue; - const hasOptimisticUpdate = !!hookData?.updateOptimistically; - - // If updateOptimistically is available, use it for immediate UI feedback - if (hasOptimisticUpdate) { - hookData.updateOptimistically(rowId, { [column.key]: newValue }); - } - - try { - // Call the update function (generic - no entity-specific logic) - if (onInlineUpdate) { - await onInlineUpdate(row, column.key, newValue); - } else if (hookData?.handleInlineUpdate) { - // Pass row as third parameter for hooks that need to merge changes with existing data - await hookData.handleInlineUpdate(rowId, { [column.key]: newValue }, row); - } - - // Only refetch if we DON'T have optimistic update (to get fresh data) - // With optimistic update, local state is already correct - if (!hasOptimisticUpdate && hookData?.refetch) { - await hookData.refetch(); - } - } catch (error) { - console.error('FormGeneratorTable: Inline update failed:', error); - // Revert optimistic update on error - if (hasOptimisticUpdate) { - hookData.updateOptimistically(rowId, { [column.key]: currentValue }); - } - // Refetch to restore consistent state on error - if (hookData?.refetch) { - await hookData.refetch(); - } - } finally { - // Remove cell from updating state - setUpdatingCells(prev => { - const newSet = new Set(prev); - newSet.delete(cellKey); - return newSet; + + const previous = inlineUpdateQueueRef.current.get(String(rowId)) || Promise.resolve(); + + const work: Promise = previous + .catch(() => undefined) + .then(async () => { + try { + // Re-resolve the row from the latest refetched snapshot so the + // merged payload reflects every server-confirmed change made by + // earlier queued toggles on this row. + const latestRow = (dataRef.current.find( + (r: any) => String(r?.[idField]) === String(rowId), + ) as T | undefined) ?? row; + + if (onInlineUpdate) { + await onInlineUpdate(latestRow, column.key, newValue); + } else if (hookData?.handleInlineUpdate) { + await hookData.handleInlineUpdate(rowId, { [column.key]: newValue }, latestRow); + } + + // Always refetch on success — the cell only ever shows backend truth. + if (hookData?.refetch) { + await hookData.refetch(); + } + } catch (error) { + console.error('FormGeneratorTable: Inline update failed:', error); + // Refetch on error too: restores the row to confirmed server state + // (the cell snaps back to the original value). + if (hookData?.refetch) { + try { await hookData.refetch(); } catch { /* swallow */ } + } + throw error; + } finally { + setUpdatingCells(prev => { + const newSet = new Set(prev); + newSet.delete(cellKey); + return newSet; + }); + } }); + + inlineUpdateQueueRef.current.set(String(rowId), work); + try { + await work; + } finally { + if (inlineUpdateQueueRef.current.get(String(rowId)) === work) { + inlineUpdateQueueRef.current.delete(String(rowId)); + } } }, [canInlineEdit, isInlineEditableColumn, idField, onInlineUpdate, hookData]); diff --git a/src/components/UiComponents/LanguageSelector/LanguageSelector.module.css b/src/components/UiComponents/LanguageSelector/LanguageSelector.module.css index 3154ee1..79bc9ca 100644 --- a/src/components/UiComponents/LanguageSelector/LanguageSelector.module.css +++ b/src/components/UiComponents/LanguageSelector/LanguageSelector.module.css @@ -2,11 +2,16 @@ display: inline-flex; align-items: center; gap: 0.35rem; + padding: 0.25rem 0.5rem; + border: 1px solid var(--color-border, rgba(0, 0, 0, 0.15)); + border-radius: 6px; + background: var(--color-surface, rgba(255, 255, 255, 0.6)); + color: var(--color-text, #1f2937); } .icon { font-size: 0.85rem; - opacity: 0.6; + opacity: 0.7; flex-shrink: 0; } @@ -18,9 +23,9 @@ font-size: 0.8rem; font-family: inherit; cursor: pointer; - padding: 0.15rem 0.3rem; + padding: 0.05rem 0.5rem 0.05rem 0.15rem; border-radius: 4px; - opacity: 0.7; + opacity: 0.95; transition: opacity 0.15s; } diff --git a/src/components/UiComponents/LanguageSelector/LanguageSelector.tsx b/src/components/UiComponents/LanguageSelector/LanguageSelector.tsx index 138076a..d543fe3 100644 --- a/src/components/UiComponents/LanguageSelector/LanguageSelector.tsx +++ b/src/components/UiComponents/LanguageSelector/LanguageSelector.tsx @@ -5,7 +5,13 @@ import styles from './LanguageSelector.module.css'; export function LanguageSelector() { const { currentLanguage, setLanguage, availableLanguages } = useLanguage(); - if (availableLanguages.length <= 1) return null; + // Always show the selector. If the backend has not (yet) returned a list, + // fall back to a static option for the currently active language so the + // control is visible even on pre-login screens / before the codes endpoint + // resolves. + const optionList = availableLanguages.length > 0 + ? availableLanguages + : [{ code: currentLanguage, label: currentLanguage.toUpperCase() } as { code: string; label: string }]; return (
@@ -14,8 +20,9 @@ export function LanguageSelector() { className={styles.select} value={currentLanguage} onChange={(e) => setLanguage(e.target.value as typeof currentLanguage)} + aria-label="Sprache / Language" > - {availableLanguages.map((lang) => ( + {optionList.map((lang) => ( diff --git a/src/components/UiComponents/Modal/Modal.module.css b/src/components/UiComponents/Modal/Modal.module.css new file mode 100644 index 0000000..11f671e --- /dev/null +++ b/src/components/UiComponents/Modal/Modal.module.css @@ -0,0 +1,77 @@ +.overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal { + background: var(--surface-color); + border-radius: 12px; + width: 90%; + max-width: 600px; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15); +} + +.sizeSm { max-width: 420px; } +.sizeMd { max-width: 600px; } +.sizeLg { max-width: 880px; } +.sizeXl { max-width: 1200px; } + +.header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--border-color); +} + +.title { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.closeButton { + background: none; + border: none; + font-size: 1.25rem; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem; + line-height: 1; + transition: color 0.2s; +} + +.closeButton:hover { + color: var(--text-primary); +} + +.content { + padding: 1.5rem; + overflow-y: auto; + flex: 1; +} + +.footer { + display: flex; + justify-content: flex-end; + gap: 0.75rem; + padding: 1rem 1.5rem; + border-top: 1px solid var(--border-color); +} + +:global(.dark-theme) .overlay { + background: rgba(0, 0, 0, 0.7); +} diff --git a/src/components/UiComponents/Modal/Modal.tsx b/src/components/UiComponents/Modal/Modal.tsx new file mode 100644 index 0000000..195625b --- /dev/null +++ b/src/components/UiComponents/Modal/Modal.tsx @@ -0,0 +1,133 @@ +/** + * Modal — central, consistent dialog component for the whole UI. + * + * Behavior contract (intentional, documented): + * - The dialog stays open until the user explicitly closes it via the X button + * (top-right) or an explicit Cancel/OK button rendered by the consumer. + * - Clicking on the dimmed overlay does NOT close the dialog (default: false). + * - Pressing Escape does NOT close the dialog (default: false). + * - Both behaviors can be opted-in via ``closeOnOverlayClick`` / + * ``closeOnEscape`` for the rare cases where this is desired. + * + * Layout: standard 3-row flex (header / scrollable content / optional footer). + * The component traps body scroll while open and is accessible via ``role=dialog``. + */ +import React, { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import styles from './Modal.module.css'; + +export type ModalSize = 'sm' | 'md' | 'lg' | 'xl'; + +export interface ModalProps { + open: boolean; + onClose: () => void; + title?: React.ReactNode; + children: React.ReactNode; + footer?: React.ReactNode; + size?: ModalSize; + closeOnOverlayClick?: boolean; + closeOnEscape?: boolean; + hideCloseButton?: boolean; + ariaLabel?: string; + className?: string; + contentClassName?: string; + testId?: string; +} + +const _SIZE_CLASS: Record = { + sm: styles.sizeSm, + md: styles.sizeMd, + lg: styles.sizeLg, + xl: styles.sizeXl, +}; + +export const Modal: React.FC = ({ + open, + onClose, + title, + children, + footer, + size = 'md', + closeOnOverlayClick = false, + closeOnEscape = false, + hideCloseButton = false, + ariaLabel, + className, + contentClassName, + testId, +}) => { + const dialogRef = useRef(null); + + useEffect(() => { + if (!open) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [open]); + + useEffect(() => { + if (!open || !closeOnEscape) return; + const handleKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.stopPropagation(); + onClose(); + } + }; + window.addEventListener('keydown', handleKey); + return () => window.removeEventListener('keydown', handleKey); + }, [open, closeOnEscape, onClose]); + + if (!open) return null; + + const handleOverlayClick = (e: React.MouseEvent) => { + if (!closeOnOverlayClick) return; + if (e.target === e.currentTarget) onClose(); + }; + + const titleId = title ? 'modal-title' : undefined; + + const node = ( +
+
+ {(title || !hideCloseButton) && ( +
+ {title ? ( +

{title}

+ ) :
+ )} +
+ {children} +
+ {footer &&
{footer}
} +
+
+ ); + + return createPortal(node, document.body); +}; + +export default Modal; diff --git a/src/components/UiComponents/Modal/index.ts b/src/components/UiComponents/Modal/index.ts new file mode 100644 index 0000000..ed1f3bc --- /dev/null +++ b/src/components/UiComponents/Modal/index.ts @@ -0,0 +1,3 @@ +export { Modal } from './Modal'; +export type { ModalProps, ModalSize } from './Modal'; +export { default } from './Modal'; diff --git a/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx b/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx index 8837a55..837a865 100644 --- a/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx +++ b/src/components/UiComponents/VoiceLanguageSelect/VoiceLanguageSelect.tsx @@ -1,67 +1,29 @@ /** * 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. + * + * Reusable picker for voice/speech-recognition language. Reads the language + * list from the central VoiceCatalog (single source of truth) — never + * hard-coded here. */ import React from 'react'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { useVoiceCatalog, useDefaultVoiceLocale } from '../../../contexts/VoiceCatalogContext'; +import type { VoiceLanguage } from '../../../api/voiceCatalogApi'; 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 type VoiceLanguageOption = VoiceLanguage; 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 + compact?: boolean; + showFlags?: boolean; 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, @@ -71,23 +33,25 @@ export const VoiceLanguageSelect: React.FC = ({ className = '', title = 'Sprache für Spracherkennung', }) => { + const { languages, isLoading } = useVoiceCatalog(); + const handleChange = (e: React.ChangeEvent) => { onChange(e.target.value); }; - + return (
@@ -96,37 +60,34 @@ export const VoiceLanguageSelect: React.FC = ({ }; /** - * Hook to manage voice language state with user profile default + * Hook to manage voice language state with user profile default. + * Initial value falls back to the catalog-derived default for the profile language. */ export const useVoiceLanguage = (initialValue?: string) => { const { currentLanguage } = useLanguage(); - - // Track if user has manually changed the language + const { languages } = useVoiceCatalog(); + const defaultLocale = useDefaultVoiceLocale(currentLanguage); + const hasManuallyChanged = React.useRef(false); - - // Initialize with user's profile language (or provided initial value) const [voiceLanguage, setVoiceLanguage] = React.useState( - initialValue || getDefaultVoiceLanguage(currentLanguage) + initialValue || defaultLocale, ); - - // 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); + setVoiceLanguage(defaultLocale); } - }, [currentLanguage, initialValue]); - - // Wrapper to track manual changes + }, [defaultLocale, initialValue]); + const handleSetVoiceLanguage = React.useCallback((newLanguage: string) => { hasManuallyChanged.current = true; setVoiceLanguage(newLanguage); }, []); - + return { voiceLanguage, setVoiceLanguage: handleSetVoiceLanguage, - voiceLanguages, + voiceLanguages: languages, }; }; diff --git a/src/components/UiComponents/VoiceLanguageSelect/index.ts b/src/components/UiComponents/VoiceLanguageSelect/index.ts index eecae14..0e5ab24 100644 --- a/src/components/UiComponents/VoiceLanguageSelect/index.ts +++ b/src/components/UiComponents/VoiceLanguageSelect/index.ts @@ -1,8 +1,6 @@ -export { - VoiceLanguageSelect, - useVoiceLanguage, - getDefaultVoiceLanguage, - voiceLanguages, +export { + VoiceLanguageSelect, + useVoiceLanguage, type VoiceLanguageOption, - type VoiceLanguageSelectProps + type VoiceLanguageSelectProps, } from './VoiceLanguageSelect'; diff --git a/src/components/UiComponents/index.ts b/src/components/UiComponents/index.ts index 92ca1e4..27623a4 100644 --- a/src/components/UiComponents/index.ts +++ b/src/components/UiComponents/index.ts @@ -20,4 +20,5 @@ export * from './AutoScroll'; export * from './Tabs'; export type { TabsProps, Tab } from './Tabs'; export * from './Toast'; -export * from './VoiceLanguageSelect'; \ No newline at end of file +export * from './VoiceLanguageSelect'; +export * from './Modal'; \ No newline at end of file diff --git a/src/components/UnifiedDataBar/ChatsTab.tsx b/src/components/UnifiedDataBar/ChatsTab.tsx index a9a1b23..86e474e 100644 --- a/src/components/UnifiedDataBar/ChatsTab.tsx +++ b/src/components/UnifiedDataBar/ChatsTab.tsx @@ -52,6 +52,22 @@ function _formatRelativeTime(dateStr?: string | number): string { return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); } +/** + * Timestamp der letzten Aktivität eines Chats: `lastMessageAt` (Backend liefert + * den Zeitstempel der letzten Nachricht) bevorzugt, sonst Fallback auf + * `updatedAt` (Workflow-Lifecycle-Zeit). Wird sowohl für Anzeige als auch + * Sortierung verwendet, damit Liste und Label konsistent sind. + */ +function _lastTouchValue(chat: ChatItem): string | number | undefined { + return chat.lastMessageAt ?? chat.updatedAt; +} + +function _lastTouchTs(chat: ChatItem): number { + const v = _lastTouchValue(chat); + if (v === undefined || v === null) return 0; + return typeof v === 'number' ? v : new Date(v).getTime(); +} + const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart, @@ -113,13 +129,7 @@ const ChatsTab: React.FC = ({ context, } const sorted = Array.from(groupMap.values()); - sorted.forEach(g => - g.chats.sort((a, b) => { - const ta = typeof a.updatedAt === 'number' ? a.updatedAt : new Date(a.updatedAt || 0).getTime(); - const tb = typeof b.updatedAt === 'number' ? b.updatedAt : new Date(b.updatedAt || 0).getTime(); - return tb - ta; - }), - ); + sorted.forEach(g => g.chats.sort((a, b) => _lastTouchTs(b) - _lastTouchTs(a))); setGroups(sorted); if (expandedGroups.size === 0 && sorted.length > 0) { @@ -218,16 +228,9 @@ const ChatsTab: React.FC = ({ context, .map(g => ({ ...g, chats: _applyFilter(g.chats) })) .filter(g => g.chats.length > 0); - const _toTs = (v?: string | number): number => - typeof v === 'number' ? v : new Date(v || 0).getTime(); - const _allChats = _filteredGroups .flatMap(g => g.chats) - .sort((a, b) => { - const ta = _toTs(a.lastMessageAt ?? a.updatedAt); - const tb = _toTs(b.lastMessageAt ?? b.updatedAt); - return tb - ta; - }); + .sort((a, b) => _lastTouchTs(b) - _lastTouchTs(a)); const _activeCount = groups.reduce((n, g) => n + g.chats.filter(c => !_isArchived(c)).length, 0); const _archivedCount = groups.reduce((n, g) => n + g.chats.filter(c => _isArchived(c)).length, 0); @@ -270,7 +273,7 @@ const ChatsTab: React.FC = ({ context, ) : ( <> - {_formatRelativeTime(chat.updatedAt)} + {_formatRelativeTime(_lastTouchValue(chat))} = ({ context, onFileSelect, onSendToChat name: f.name, parentId: f.parentId ?? null, fileCount: f.fileCount ?? 0, - neutralize: (f as any).neutralize ?? false, + neutralize: f.neutralize ?? false, + scope: f.scope ?? 'personal', })); }, [folders]); @@ -178,6 +179,16 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat } }, [refreshFolders, refreshTreeFiles]); + const _onFolderScopeChange = useCallback(async (folderId: string, newScope: string) => { + try { + await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope }); + await refreshFolders(); + await refreshTreeFiles(); + } catch (err) { + console.error('Failed to change folder scope:', err); + } + }, [refreshFolders, refreshTreeFiles]); + if (treeFilesLoading && treeFileNodes.length === 0) { return
{t('Dateien laden')}
; } @@ -268,6 +279,7 @@ const FilesTab: React.FC = ({ context, onFileSelect, onSendToChat onDownloadFolder={handleDownloadFolder} onScopeChange={_onScopeChange} onNeutralizeToggle={_onNeutralizeToggle} + onFolderScopeChange={_onFolderScopeChange} onFolderNeutralizeToggle={_onFolderNeutralizeToggle} onSendToChat={onSendToChat} /> diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx index aec1197..ad1f6d1 100644 --- a/src/components/UnifiedDataBar/SourcesTab.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -1097,12 +1097,24 @@ const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ {node.label}
- {/* Chat-Senden: always visible */} + {/* Dynamic action: Remove (only when DS exists) — placed LEFT of the + * stable trio so the trio always anchors at the right edge. */} + {ds && ( + + )} + + {/* ── Stable trio: chat | scope | neutralize (always in this order) ── */} - - {/* Scope: own DS → cycle, no DS → create DS then cycle */} - - {/* Neutralize: own DS → toggle, no DS → create DS then toggle */} - {/* Remove: only when DS exists */} - {ds && ( - - )} -
{node.expanded && node.children && node.children.length > 0 && ( @@ -1366,7 +1363,18 @@ const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ {node.tableCount} {t('Tabellen')} - {/* Chat: always visible */} + {/* Dynamic Remove (left of stable trio) */} + {wildcardFds && ( + + )} + + {/* ── Stable trio: chat | scope | neutralize ── */} - - {/* Scope: own wildcard-FDS → cycle, otherwise create then cycle */} - - {/* Neutralize: own wildcard-FDS → toggle, otherwise create then toggle */} - {/* Remove: only when wildcard-FDS exists */} - {wildcardFds && ( - - )} -
{node.expanded && node.tables && node.tables.length > 0 && ( @@ -1587,20 +1580,29 @@ const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ )} - {/* Chat: always visible */} + {/* Dynamic Remove (left of stable trio) */} + {fds && onRemoveFds && ( + + )} + + {/* ── Stable trio: chat | scope | neutralize ── */} - - {/* Scope: own FDS → cycle, otherwise create then cycle */} - - {/* Neutralize: own FDS → toggle, otherwise create then toggle */} - {/* Remove: only when FDS exists */} - {fds && onRemoveFds && ( - - )} - {/* Expandable field sub-nodes */} @@ -1732,26 +1721,30 @@ const _FeatureFieldRow: React.FC<_FeatureFieldRowProps> = ({ {fieldName} - {/* Chat: always visible */} + {/* ── Stable trio: chat | scope | neutralize ── */} - - {/* Neutralize: own FDS → clickable, otherwise dimmed */} + + {_SCOPE_ICONS[inheritedScope || 'personal']} + {fds && onToggleNeutralizeField ? ( ) : ( {'\uD83D\uDD12'} )} - - {/* Scope: inherited indicator */} - - {_SCOPE_ICONS[inheritedScope || 'personal']} - ); }; @@ -1949,7 +1934,18 @@ const _ParentRecordRow: React.FC<_ParentRecordRowProps> = ({ {record.displayLabel} - {/* Chat: always visible */} + {/* Dynamic Remove (left of stable trio) */} + {fds && onRemoveFds && ( + + )} + + {/* ── Stable trio: chat | scope | neutralize ── */} - - {/* Scope: own FDS → clickable, otherwise dimmed */} {fds && onCycleScope ? ( ) : ( {_SCOPE_ICONS[inheritedScope || 'personal']} )} - - {/* Neutralize: own FDS → clickable, otherwise dimmed */} {fds && onToggleNeutralize ? ( ) : ( {'\uD83D\uDD12'} )} - {/* Remove: only when FDS exists */} - {fds && onRemoveFds && ( - - )} - {record.expanded && ( diff --git a/src/contexts/VoiceCatalogContext.tsx b/src/contexts/VoiceCatalogContext.tsx new file mode 100644 index 0000000..0150460 --- /dev/null +++ b/src/contexts/VoiceCatalogContext.tsx @@ -0,0 +1,151 @@ +/** + * VoiceCatalogContext + * + * Loads the central voice/language catalog from the backend exactly once and + * makes it available to every component via `useVoiceCatalog()`. + * + * Provides convenience helpers for ISO ⇄ BCP-47 lookups and curated default + * voices, mirroring the backend `voiceCatalog` API. Components MUST NOT keep + * their own static language lists. + */ + +import React, { + createContext, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react'; +import { fetchVoiceCatalog, VoiceLanguage } from '../api/voiceCatalogApi'; + +interface VoiceCatalogContextType { + languages: VoiceLanguage[]; + isLoading: boolean; + error: string | null; + getByBcp47: (code: string | null | undefined) => VoiceLanguage | undefined; + getByIso: (iso: string | null | undefined) => VoiceLanguage | undefined; + isoToBcp47: (iso: string | null | undefined) => string | undefined; + getDefaultVoice: (bcp47: string | null | undefined) => string | null; +} + +const VoiceCatalogContext = createContext(undefined); + +interface VoiceCatalogProviderProps { + children: ReactNode; +} + +export const VoiceCatalogProvider: React.FC = ({ children }) => { + const [languages, setLanguages] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const data = await fetchVoiceCatalog(); + if (!cancelled) { + setLanguages(data); + setError(null); + } + } catch (err: any) { + if (!cancelled) { + setError(err?.message || 'Failed to load voice catalog'); + setLanguages([]); + } + } finally { + if (!cancelled) setIsLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const byBcp47 = useMemo(() => { + const map = new Map(); + for (const v of languages) map.set(v.bcp47.toLowerCase(), v); + return map; + }, [languages]); + + const byIso = useMemo(() => { + const map = new Map(); + for (const v of languages) { + if (!map.has(v.iso.toLowerCase())) map.set(v.iso.toLowerCase(), v); + } + return map; + }, [languages]); + + const getByBcp47 = useCallback( + (code: string | null | undefined) => + code ? byBcp47.get(code.trim().toLowerCase()) : undefined, + [byBcp47], + ); + + const getByIso = useCallback( + (iso: string | null | undefined) => + iso ? byIso.get(iso.trim().toLowerCase()) : undefined, + [byIso], + ); + + const isoToBcp47 = useCallback( + (iso: string | null | undefined): string | undefined => { + if (!iso) return undefined; + const trimmed = iso.trim(); + if (!trimmed) return undefined; + if (trimmed.includes('-')) { + const canonical = byBcp47.get(trimmed.toLowerCase()); + return canonical ? canonical.bcp47 : trimmed; + } + const entry = byIso.get(trimmed.toLowerCase()); + if (entry) return entry.bcp47; + return `${trimmed.toLowerCase()}-${trimmed.toUpperCase()}`; + }, + [byBcp47, byIso], + ); + + const getDefaultVoice = useCallback( + (bcp47: string | null | undefined): string | null => { + const entry = getByBcp47(bcp47); + return entry?.defaultVoice ?? null; + }, + [getByBcp47], + ); + + const value = useMemo( + () => ({ + languages, + isLoading, + error, + getByBcp47, + getByIso, + isoToBcp47, + getDefaultVoice, + }), + [languages, isLoading, error, getByBcp47, getByIso, isoToBcp47, getDefaultVoice], + ); + + return ( + {children} + ); +}; + +export const useVoiceCatalog = (): VoiceCatalogContextType => { + const ctx = useContext(VoiceCatalogContext); + if (!ctx) { + throw new Error('useVoiceCatalog must be used within VoiceCatalogProvider'); + } + return ctx; +}; + +/** + * Map a profile language (ISO short code) to a default voice locale. + * Returns the catalog's BCP-47 for the ISO if available, else falls back to + * `de-DE` so the UI always has a deterministic starting value. + */ +export const useDefaultVoiceLocale = (profileLanguage?: string | null): string => { + const { isoToBcp47 } = useVoiceCatalog(); + return isoToBcp47(profileLanguage) || 'de-DE'; +}; diff --git a/src/hooks/useMandates.ts b/src/hooks/useMandates.ts index 31d9621..ebb31f9 100644 --- a/src/hooks/useMandates.ts +++ b/src/hooks/useMandates.ts @@ -17,14 +17,16 @@ import { deleteMandate as deleteMandateApi, hardDeleteMandate as hardDeleteMandateApi, type Mandate, + type MandateCreateData, type MandateUpdateData, type PaginationParams } from '../api/mandateApi'; import type { AttributeDefinition as FormGenAttr } from '../components/FormGenerator/FormGeneratorForm'; import { getMandateBillingFormAttributes } from '../utils/mandateBillingFormMerge'; +import { validateMandateName } from '../utils/mandateNameUtils'; // Re-export types -export type { Mandate, MandateUpdateData, PaginationParams }; +export type { Mandate, MandateCreateData, MandateUpdateData, PaginationParams }; export interface AttributeDefinition { name: string; @@ -169,7 +171,19 @@ export function useAdminMandates() { // Create mandate const handleCreate = useCallback(async (mandateData: Partial): Promise => { try { - const created = await createMandateApi(request, mandateData); + const label = typeof mandateData.label === 'string' ? mandateData.label.trim() : ''; + if (!label) { + console.error('createMandate: label (Voller Name) is required'); + return null; + } + if (typeof mandateData.name === 'string' && mandateData.name.length > 0) { + const slugErr = validateMandateName(mandateData.name); + if (slugErr) { + console.error(`createMandate: invalid Kurzzeichen — ${slugErr}`); + return null; + } + } + const created = await createMandateApi(request, { ...mandateData, label } as MandateCreateData); await fetchMandates(); return created ?? null; } catch (error: any) { @@ -181,6 +195,21 @@ export function useAdminMandates() { // Update mandate const handleUpdate = useCallback(async (mandateId: string, updateData: MandateUpdateData): Promise => { try { + if ('label' in updateData) { + const lbl = typeof updateData.label === 'string' ? updateData.label.trim() : ''; + if (!lbl) { + console.error('updateMandate: label (Voller Name) must not be empty'); + return false; + } + updateData = { ...updateData, label: lbl }; + } + if ('name' in updateData && typeof updateData.name === 'string') { + const slugErr = validateMandateName(updateData.name); + if (slugErr) { + console.error(`updateMandate: invalid Kurzzeichen — ${slugErr}`); + return false; + } + } updateOptimistically(mandateId, updateData); await updateMandateApi(request, mandateId, updateData); return true; @@ -295,9 +324,17 @@ export function useMandateFormAttributes() { }, [load]); const formAttributes: FormGenAttr[] = useMemo(() => { - return attributes + const list = attributes .filter(attr => attr.name !== 'id') .map(attr => ({ ...attr, type: attr.type })) as FormGenAttr[]; + + const labelIdx = list.findIndex(a => a.name === 'label'); + const nameIdx = list.findIndex(a => a.name === 'name'); + if (labelIdx >= 0 && nameIdx >= 0 && nameIdx < labelIdx) { + const [labelAttr] = list.splice(labelIdx, 1); + list.splice(nameIdx, 0, labelAttr); + } + return list; }, [attributes]); const createFormAttributes: FormGenAttr[] = useMemo( diff --git a/src/hooks/useStore.ts b/src/hooks/useStore.ts index 054eaa6..2ecd1a1 100644 --- a/src/hooks/useStore.ts +++ b/src/hooks/useStore.ts @@ -19,12 +19,23 @@ import { } from '../api/storeApi'; import { useFeatureStore } from '../stores/featureStore'; +/** + * Build a stable key identifying a single Store action button so the spinner + * can be scoped to exactly that button (one feature × one mandate / instance) + * instead of greying out every button of the feature. + */ +export const _storeActionKey = { + activate: (featureCode: string, mandateId?: string) => `activate:${featureCode}:${mandateId ?? ''}`, + deactivate: (featureCode: string, instanceId: string) => `deactivate:${featureCode}:${instanceId}`, +}; + interface UseStoreReturn { features: StoreFeature[]; mandates: UserMandate[]; subscriptionInfo: SubscriptionInfo | null; loading: boolean; - actionLoading: string | null; + /** Set of in-flight action keys (see ``_storeActionKey``) — one entry per button currently processing. */ + actionLoading: Set; error: string | null; loadStore: () => Promise; loadSubscriptionInfo: (mandateId?: string) => Promise; @@ -37,10 +48,27 @@ export function useStore(): UseStoreReturn { const [mandates, setMandates] = useState([]); const [subscriptionInfo, setSubscriptionInfo] = useState(null); const [loading, setLoading] = useState(true); - const [actionLoading, setActionLoading] = useState(null); + const [actionLoading, setActionLoading] = useState>(() => new Set()); const [error, setError] = useState(null); const featureStore = useFeatureStore(); + const _markBusy = useCallback((key: string) => { + setActionLoading(prev => { + const next = new Set(prev); + next.add(key); + return next; + }); + }, []); + + const _markIdle = useCallback((key: string) => { + setActionLoading(prev => { + if (!prev.has(key)) return prev; + const next = new Set(prev); + next.delete(key); + return next; + }); + }, []); + const loadSubscriptionInfo = useCallback(async (mandateId?: string) => { try { const info = await fetchSubscriptionInfo(mandateId); @@ -81,7 +109,8 @@ export function useStore(): UseStoreReturn { }, [featureStore, loadStore]); const activate = useCallback(async (featureCode: string, mandateId?: string) => { - setActionLoading(featureCode); + const key = _storeActionKey.activate(featureCode, mandateId); + _markBusy(key); setError(null); try { await activateStoreFeature(featureCode, mandateId); @@ -90,12 +119,13 @@ export function useStore(): UseStoreReturn { const msg = err instanceof Error ? err.message : 'Activation failed'; setError(msg); } finally { - setActionLoading(null); + _markIdle(key); } - }, [_refreshAfterAction]); + }, [_refreshAfterAction, _markBusy, _markIdle]); const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => { - setActionLoading(featureCode); + const key = _storeActionKey.deactivate(featureCode, instanceId); + _markBusy(key); setError(null); try { await deactivateStoreFeature(featureCode, mandateId, instanceId); @@ -104,9 +134,9 @@ export function useStore(): UseStoreReturn { const msg = err instanceof Error ? err.message : 'Deactivation failed'; setError(msg); } finally { - setActionLoading(null); + _markIdle(key); } - }, [_refreshAfterAction]); + }, [_refreshAfterAction, _markBusy, _markIdle]); return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate }; } diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts index b9797d7..acd7242 100644 --- a/src/hooks/useUsers.ts +++ b/src/hooks/useUsers.ts @@ -32,11 +32,12 @@ export function useCurrentUser() { const cachedUser = getUserDataCache(); if (cachedUser && cachedUser.username) { // Use cached user data - permissions are checked via RBAC API, not client-side - // Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin flag instead + // Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin/isPlatformAdmin flags instead setUser(cachedUser); console.log('✅ Using cached user data from sessionStorage (persists during session):', { username: cachedUser.username, - isSysAdmin: cachedUser.isSysAdmin + isSysAdmin: cachedUser.isSysAdmin, + isPlatformAdmin: cachedUser.isPlatformAdmin }); return; } @@ -72,6 +73,7 @@ export function useCurrentUser() { console.log('📦 User data received from API:', { username: data?.username, isSysAdmin: data?.isSysAdmin, + isPlatformAdmin: data?.isPlatformAdmin, allKeys: data ? Object.keys(data) : [] }); @@ -85,11 +87,12 @@ export function useCurrentUser() { } // Cache user data (permissions are checked via RBAC API) - // Note: roleLabels is deprecated - use isSysAdmin flag for admin checks + // Note: roleLabels is deprecated - use isSysAdmin/isPlatformAdmin flags for admin checks setUserDataCache(data); console.log('✅ User data fetched from API and cached:', { username: data.username, - isSysAdmin: data.isSysAdmin + isSysAdmin: data.isSysAdmin, + isPlatformAdmin: data.isPlatformAdmin }); setUser(data); } catch (error: any) { @@ -215,11 +218,12 @@ export function useCurrentUser() { const cachedUser = getUserDataCache(); if (cachedUser && cachedUser.username) { // Use cached user data - permissions are checked via RBAC API - // Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin flag instead + // Note: roleLabels is deprecated in Multi-Tenant architecture - use isSysAdmin/isPlatformAdmin flags instead setUser(cachedUser); console.log('✅ Using cached user data from sessionStorage on mount:', { username: cachedUser.username, - isSysAdmin: cachedUser.isSysAdmin + isSysAdmin: cachedUser.isSysAdmin, + isPlatformAdmin: cachedUser.isPlatformAdmin }); } @@ -800,24 +804,26 @@ export function useUserOperations() { } }; - // Generic inline update handler for FormGeneratorTable - // Must merge changes with existing row data because backend requires full object - // The existingRow parameter is passed from FormGeneratorTable which has access to row data - const handleInlineUpdate = async (userId: string, changes: Partial, existingRow?: any) => { - if (!existingRow) { - throw new Error(`Existing row data required for inline update`); + // Generic inline update handler for FormGeneratorTable. + // + // The User PUT endpoint accepts PARTIAL payloads — only fields explicitly + // present are applied; missing fields keep their stored value. We therefore + // forward ONLY the changed cells. This avoids two classes of bugs: + // 1. Stale snapshot: spreading ``existingRow`` onto the payload would + // overwrite fields with whatever the client last loaded, even if the + // backend has been updated since (e.g. by a parallel admin action). + // 2. Missing-field default-flip: previously, any non-listed field (e.g. + // ``isSysAdmin`` while toggling ``isPlatformAdmin``) was absent from + // the merged payload and the Pydantic ``User`` body on the backend + // filled it with ``False``, silently dropping the other privileged flag. + // + // ``existingRow`` is kept in the signature for forward-compat with table + // hooks but is no longer consulted to build the payload. + const handleInlineUpdate = async (userId: string, changes: Partial, _existingRow?: any) => { + if (!changes || Object.keys(changes).length === 0) { + throw new Error('No fields to update'); } - - // Merge changes with existing row data (backend requires full object with required fields) - const mergedData: UserUpdateData = { - username: existingRow.username, - email: existingRow.email, - enabled: existingRow.enabled, - roleLabels: existingRow.roleLabels, - ...changes - }; - - const result = await handleUserUpdate(userId, mergedData); + const result = await handleUserUpdate(userId, changes); if (!result.success) { throw new Error(result.error || 'Failed to update'); } diff --git a/src/layouts/FeatureLayout.tsx b/src/layouts/FeatureLayout.tsx index ae68fd9..c745ccf 100644 --- a/src/layouts/FeatureLayout.tsx +++ b/src/layouts/FeatureLayout.tsx @@ -11,6 +11,7 @@ import { useCurrentInstance } from '../hooks/useCurrentInstance'; import { useFeaturesInitialized, useFeaturesLoading } from '../stores/featureStore'; import useNavigation from '../hooks/useNavigation'; import styles from './FeatureLayout.module.css'; +import { mandateDisplayLabel } from '../utils/mandateDisplayUtils'; import { useLanguage } from '../providers/language/LanguageContext'; @@ -115,7 +116,9 @@ export const FeatureLayout: React.FC = () => { {/* Header mit Instanz-Info */}
- {navLabels?.mandate || mandate?.label || mandate?.name} + + {navLabels?.mandate || (mandate ? mandateDisplayLabel(mandate) : '')} + / {navLabels?.feature || feature?.code} / diff --git a/src/pages/AutomationsDashboardPage.tsx b/src/pages/AutomationsDashboardPage.tsx index 00e9110..d3a23d3 100644 --- a/src/pages/AutomationsDashboardPage.tsx +++ b/src/pages/AutomationsDashboardPage.tsx @@ -320,11 +320,10 @@ const _RunTracingModal: React.FC<_RunTracingModalProps> = ({ run, onClose }) => }, [steps]); return ( -
+
e.stopPropagation()} >
diff --git a/src/pages/ComplianceAuditPage.tsx b/src/pages/ComplianceAuditPage.tsx index d38f190..b4043a0 100644 --- a/src/pages/ComplianceAuditPage.tsx +++ b/src/pages/ComplianceAuditPage.tsx @@ -19,6 +19,7 @@ import { useUserMandates } from '../hooks/useUserMandates'; import { useConfirm } from '../hooks/useConfirm'; import { FormGeneratorTable, ColumnConfig } from '../components/FormGenerator/FormGeneratorTable'; import styles from './ComplianceAuditPage.module.css'; +import { mandateDisplayLabel } from '../utils/mandateDisplayUtils'; const CHART_COLORS = ['#1976d2', '#00897b', '#6a1b9a', '#e65100', '#5d4037', '#455a64', '#c62828', '#2e7d32']; @@ -110,7 +111,11 @@ interface AuditStats { neutralizationPercent: number; } -interface Mandate { id: string; name?: string; label?: string; } +interface Mandate { + id: string; + name?: string; + label?: string; +} interface ContentModalData { row: any; @@ -554,7 +559,7 @@ export const ComplianceAuditPage: React.FC = () => { > {mandates.map(m => ( - + ))}
diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 7795ba4..63895e6 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -131,7 +131,7 @@ function Login() { return (
-
+
diff --git a/src/pages/PasswordResetRequest.tsx b/src/pages/PasswordResetRequest.tsx index 59e21a6..8e958e1 100644 --- a/src/pages/PasswordResetRequest.tsx +++ b/src/pages/PasswordResetRequest.tsx @@ -58,7 +58,7 @@ function PasswordResetRequest() { return (
-
+
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 7359f1b..05b4a3b 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -126,7 +126,7 @@ function Register() { return (
-
+
diff --git a/src/pages/Reset.tsx b/src/pages/Reset.tsx index ffa2524..cd8ca1b 100644 --- a/src/pages/Reset.tsx +++ b/src/pages/Reset.tsx @@ -99,7 +99,7 @@ function Reset() { return (
-
+
@@ -142,7 +142,7 @@ function Reset() { return (
-
+
diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index dd2ede9..ead846c 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -11,6 +11,7 @@ import { setUserDataCache, getUserDataCache } from '../utils/userCache'; import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm'; import { useApiRequest } from '../hooks/useApi'; +import { useVoiceCatalog } from '../contexts/VoiceCatalogContext'; import styles from './Settings.module.css'; // ============================================================================= @@ -68,8 +69,8 @@ const ProfileEditModal: React.FC = ({ isOpen, onClose, us if (!isOpen) return null; return ( -
-
e.stopPropagation()}> +
+

{t('Profil bearbeiten')}

@@ -92,6 +93,7 @@ interface VoiceMapEntry { language: string; voiceName: string; } const VoiceSettingsTab: React.FC = () => { const { t } = useLanguage(); const { request } = useApiRequest(); + const { languages: voiceCatalog } = useVoiceCatalog(); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); @@ -100,7 +102,6 @@ const VoiceSettingsTab: React.FC = () => { const [success, setSuccess] = useState(null); const [sttLanguage, setSttLanguage] = useState('de-DE'); - const [languages, setLanguages] = useState([]); const [voiceMap, setVoiceMap] = useState([]); const [addLanguage, setAddLanguage] = useState('de-DE'); @@ -111,13 +112,7 @@ const VoiceSettingsTab: React.FC = () => { const _loadSettings = useCallback(async () => { setLoading(true); try { - const [prefsData, languagesData] = await Promise.all([ - request({ url: '/api/voice/preferences', method: 'get' }), - request({ url: '/api/voice/languages', method: 'get' }), - ]); - - const langList = (languagesData as any)?.languages || []; - setLanguages(langList); + const prefsData = await request({ url: '/api/voice/preferences', method: 'get' }); const prefs = prefsData as any; setSttLanguage(prefs?.sttLanguage || 'de-DE'); @@ -203,16 +198,9 @@ const VoiceSettingsTab: React.FC = () => { }, [request]); const _getLanguageName = useCallback((code: string) => { - const found = languages.find((l: any) => (l.code || l) === code); - return found?.name || found?.code || code; - }, [languages]); - - const _defaultLangs = [ - { code: 'de-DE', name: 'Deutsch' }, { code: 'en-US', name: 'English (US)' }, - { code: 'fr-FR', name: 'Francais' }, { code: 'it-IT', name: 'Italiano' }, - { code: 'es-ES', name: 'Espanol' }, - ]; - const _displayLanguages = languages.length > 0 ? languages : _defaultLangs; + const entry = voiceCatalog.find(l => l.bcp47.toLowerCase() === code.toLowerCase()); + return entry ? `${entry.flag ? entry.flag + ' ' : ''}${entry.label}` : code; + }, [voiceCatalog]); if (loading) return
{t('Einstellungen werden geladen')}
; @@ -230,8 +218,10 @@ const VoiceSettingsTab: React.FC = () => {
@@ -274,8 +264,10 @@ const VoiceSettingsTab: React.FC = () => {
diff --git a/src/pages/Store.module.css b/src/pages/Store.module.css index d12f7e4..2a343fd 100644 --- a/src/pages/Store.module.css +++ b/src/pages/Store.module.css @@ -5,9 +5,9 @@ .store { box-sizing: border-box; width: 100%; - max-width: 1000px; + max-width: 1600px; margin: 0 auto; - padding: 2rem; + padding: 2rem clamp(1rem, 2vw, 2.5rem); min-width: 0; } @@ -78,8 +78,9 @@ /* Grid */ .grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); - gap: 1.25rem; + grid-template-columns: repeat(auto-fill, minmax(clamp(260px, 22vw, 340px), 1fr)); + gap: 1.5rem; + width: 100%; } /* Card */ @@ -171,18 +172,47 @@ display: flex; flex-direction: column; gap: 0.5rem; + padding: 0.75rem; + background: var(--surface-alt, rgba(0, 0, 0, 0.025)); + border-radius: 8px; } .instanceRow { display: flex; align-items: center; justify-content: space-between; - gap: 0.5rem; + gap: 0.75rem; + padding: 0.4rem 0.6rem; + background: var(--surface-color, #ffffff); + border: 1px solid var(--border-color, #e5e7eb); + border-radius: 6px; } .instanceInfo { + display: flex; + flex-direction: column; + gap: 0.1rem; min-width: 0; - overflow: hidden; + flex: 1; +} + +.instanceLabel { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); + line-height: 1.3; + overflow-wrap: anywhere; +} + +.instanceMandate { + font-size: 0.75rem; + color: var(--text-secondary, #555); + font-weight: 400; + line-height: 1.3; + overflow-wrap: anywhere; } .deactivateButtonSmall { @@ -326,6 +356,23 @@ color: var(--success-color, #34d399); } +:global(.dark-theme) .instanceList { + background: rgba(255, 255, 255, 0.03); +} + +:global(.dark-theme) .instanceRow { + background: var(--surface-dark, #1f1f1f); + border-color: var(--border-dark, #333); +} + +:global(.dark-theme) .instanceLabel { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .instanceMandate { + color: var(--text-secondary-dark, #aaa); +} + :global(.dark-theme) .statusInactive { background: var(--surface-dark, #2a2a2a); color: var(--text-secondary-dark, #aaa); diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx index f256cbb..e044520 100644 --- a/src/pages/Store.tsx +++ b/src/pages/Store.tsx @@ -7,7 +7,8 @@ import React from 'react'; import { FaCogs, FaComments, FaHeadset, FaProjectDiagram, FaShieldAlt } from 'react-icons/fa'; import { useLanguage } from '../providers/language/LanguageContext'; -import { useStore } from '../hooks/useStore'; +import { mandateDisplayLabel } from '../utils/mandateDisplayUtils'; +import { useStore, _storeActionKey } from '../hooks/useStore'; import type { StoreFeature, UserMandate } from '../api/storeApi'; import { formatBinaryDataSizeFromMebibytes } from '../utils/formatDataSize'; import styles from './Store.module.css'; @@ -39,7 +40,7 @@ function _storeCardDescription(feature: StoreFeature): string { interface FeatureCardProps { feature: StoreFeature; mandates: UserMandate[]; - actionLoading: string | null; + actionLoading: Set; onActivate: (code: string, mandateId?: string) => void; onDeactivate: (code: string, mandateId: string, instanceId: string) => void; } @@ -52,7 +53,6 @@ const FeatureCard: React.FC = ({ onDeactivate, }) => { const { t } = useLanguage(); - const isProcessing = actionLoading === feature.featureCode; const icon = FEATURE_ICONS[feature.featureCode]; const activeInstances = feature.instances.filter(inst => inst.isActive); const hasActive = activeInstances.length > 0; @@ -74,23 +74,37 @@ const FeatureCard: React.FC = ({ {activeInstances.length > 0 && (
- {activeInstances.map((inst) => ( -
-
- - - {inst.mandateName || inst.label} - + {activeInstances.map((inst) => { + const instanceLabel = (inst.label && inst.label.trim()) || feature.label; + const mandateLabel = inst.mandateName || ''; + const deactivateKey = _storeActionKey.deactivate(feature.featureCode, inst.instanceId); + const isDeactivating = actionLoading.has(deactivateKey); + return ( +
+
+ + + {instanceLabel} + + {mandateLabel && ( + + {t('Mandant')}: {mandateLabel} + + )} +
+
- -
- ))} + ); + })}
)} @@ -104,18 +118,22 @@ const FeatureCard: React.FC = ({ )}
- {feature.canActivate && mandates.map((m) => ( - - ))} + {feature.canActivate && mandates.map((m) => { + const activateKey = _storeActionKey.activate(feature.featureCode, m.id); + const isActivating = actionLoading.has(activateKey); + return ( + + ); + })}
); diff --git a/src/pages/admin/AccessManagementHub.tsx b/src/pages/admin/AccessManagementHub.tsx index 3a8565d..ede8871 100644 --- a/src/pages/admin/AccessManagementHub.tsx +++ b/src/pages/admin/AccessManagementHub.tsx @@ -24,9 +24,10 @@ import { FeatureInstanceWizard } from './wizards/FeatureInstanceWizard'; import { InstanceHierarchyView } from './InstanceHierarchyView'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils'; function getMandateName(mandate: Mandate): string { - return mandate.label || mandate.name || mandate.id; + return mandateDisplayLabel(mandate); } function getFeatureLabel(feature: Feature): string { diff --git a/src/pages/admin/AdminFeatureAccessPage.tsx b/src/pages/admin/AdminFeatureAccessPage.tsx index f4343ca..4fbd307 100644 --- a/src/pages/admin/AdminFeatureAccessPage.tsx +++ b/src/pages/admin/AdminFeatureAccessPage.tsx @@ -19,6 +19,7 @@ import { TextField } from '../../components/UiComponents/TextField'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils'; export const AdminFeatureAccessPage: React.FC = () => { const { t } = useLanguage(); @@ -336,11 +337,6 @@ export const AdminFeatureAccessPage: React.FC = () => { } }; - // Get mandate name - const getMandateName = (mandate: Mandate) => { - return mandate.label || mandate.name || mandate.id; - }; - // Get feature label const getFeatureLabel = (code: string) => { const feature = features.find(f => f.code === code); @@ -385,7 +381,7 @@ export const AdminFeatureAccessPage: React.FC = () => { {mandates.map(m => ( ))} diff --git a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx index d329920..b91937d 100644 --- a/src/pages/admin/AdminFeatureInstanceUsersPage.tsx +++ b/src/pages/admin/AdminFeatureInstanceUsersPage.tsx @@ -17,6 +17,7 @@ import api from '../../api'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils'; export const AdminFeatureInstanceUsersPage: React.FC = () => { const { t } = useLanguage(); @@ -92,7 +93,7 @@ export const AdminFeatureInstanceUsersPage: React.FC = () => { allOptions.push({ mandateId: mandate.id, instanceId: inst.id, - mandateName: mandate.label || mandate.name || mandate.id, + mandateName: mandateDisplayLabel(mandate), instanceLabel: inst.label || inst.id, featureCode: inst.featureCode, combinedKey: `${mandate.id}:${inst.id}`, diff --git a/src/pages/admin/AdminInvitationsPage.tsx b/src/pages/admin/AdminInvitationsPage.tsx index 8fc24ea..2f76da0 100644 --- a/src/pages/admin/AdminInvitationsPage.tsx +++ b/src/pages/admin/AdminInvitationsPage.tsx @@ -16,6 +16,7 @@ import api from '../../api'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils'; export const AdminInvitationsPage: React.FC = () => { const { t } = useLanguage(); @@ -235,10 +236,6 @@ export const AdminInvitationsPage: React.FC = () => { } }; - // Get mandate name - const getMandateName = (mandate: Mandate) => { - return mandate.label || mandate.name || mandate.id; - }; if (error && !selectedMandateId) { return ( @@ -280,7 +277,7 @@ export const AdminInvitationsPage: React.FC = () => { {mandates.map(m => ( ))} diff --git a/src/pages/admin/AdminMandateRolePermissionsPage.tsx b/src/pages/admin/AdminMandateRolePermissionsPage.tsx index 09e208f..0b6eac8 100644 --- a/src/pages/admin/AdminMandateRolePermissionsPage.tsx +++ b/src/pages/admin/AdminMandateRolePermissionsPage.tsx @@ -36,6 +36,7 @@ import { import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils'; // Types for cleanup result interface DuplicateGroup { @@ -279,7 +280,7 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { > {mandates.map(mandate => ( ))} @@ -388,8 +389,8 @@ export const AdminMandateRolePermissionsPage: React.FC = () => { {/* Cleanup Duplicates Modal */} {showCleanupModal && ( -
-
e.stopPropagation()}> +
+

diff --git a/src/pages/admin/AdminMandateRolesPage.tsx b/src/pages/admin/AdminMandateRolesPage.tsx index ee5b5fe..7d36a51 100644 --- a/src/pages/admin/AdminMandateRolesPage.tsx +++ b/src/pages/admin/AdminMandateRolesPage.tsx @@ -25,6 +25,7 @@ import api from '../../api'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils'; export const AdminMandateRolesPage: React.FC = () => { const { t, currentLanguage } = useLanguage(); @@ -273,11 +274,6 @@ export const AdminMandateRolesPage: React.FC = () => { setEditingRole(role); }; - // Get mandate name - const getMandateName = (mandate: Mandate) => { - return mandate.label || mandate.name || mandate.id; - }; - if (error && !selectedMandateId) { return (
@@ -334,7 +330,7 @@ export const AdminMandateRolesPage: React.FC = () => { {mandates.map(m => ( ))} diff --git a/src/pages/admin/AdminMandatesPage.tsx b/src/pages/admin/AdminMandatesPage.tsx index bce689f..327f0e2 100644 --- a/src/pages/admin/AdminMandatesPage.tsx +++ b/src/pages/admin/AdminMandatesPage.tsx @@ -60,16 +60,16 @@ export const AdminMandatesPage: React.FC = () => { const [editingFormData, setEditingFormData] = useState | null>(null); const [editingBillingWarning, setEditingBillingWarning] = useState(null); - const isSysAdmin = getUserDataCache()?.isSysAdmin === true; + const isPlatformAdmin = getUserDataCache()?.isPlatformAdmin === true; // MandateAdmin: only label + billing fields editable; rest readonly const _MANDATE_ADMIN_EDITABLE = new Set(['label', 'warningThresholdPercent', 'notifyOnWarning', 'notifyEmails']); const editFormAttrs: AttributeDefinition[] = useMemo(() => { - if (isSysAdmin) return formAttributesWithBilling; + if (isPlatformAdmin) return formAttributesWithBilling; return formAttributesWithBilling.map(attr => _MANDATE_ADMIN_EDITABLE.has(attr.name) ? attr : { ...attr, editable: false, readonly: true } ); - }, [formAttributesWithBilling, isSysAdmin]); + }, [formAttributesWithBilling, isPlatformAdmin]); // Check if user can create const canCreate = permissions?.create !== 'n'; @@ -138,12 +138,15 @@ export const AdminMandatesPage: React.FC = () => { return; } const entered = await prompt( - t('Um den Mandanten "{name}" zu deaktivieren (Soft-Delete), geben Sie den Namen ein:', { name: mandate.name }), + t( + 'Um den Mandanten zu deaktivieren (Soft-Delete), geben Sie das Kurzzeichen «{slug}» exakt ein (Anzeigename: «{label}»).', + { slug: mandate.name, label: mandate.label || mandate.name } + ), { title: t('Mandat deaktivieren'), confirmLabel: t('Deaktivieren'), variant: 'danger', placeholder: mandate.name }, ); if (entered === null) return; if (entered !== mandate.name) { - showWarning(t('Abgebrochen'), t('Der eingegebene Name stimmt nicht überein.')); + showWarning(t('Abgebrochen'), t('Das eingegebene Kurzzeichen stimmt nicht überein.')); return; } await handleDelete(mandate.id); @@ -155,17 +158,23 @@ export const AdminMandatesPage: React.FC = () => { return; } const entered = await prompt( - t('ACHTUNG: Dies löscht den Mandanten "{name}" unwiderruflich inkl. aller Subscriptions, Features, Benutzer-Zuweisungen und Daten. Geben Sie den exakten Namen ein:', { name: mandate.name }), + t( + 'ACHTUNG: Dies löscht den Mandanten unwiderruflich inkl. aller Subscriptions, Features, Benutzer-Zuweisungen und Daten. Geben Sie das Kurzzeichen «{slug}» exakt ein (Anzeigename: «{label}»).', + { slug: mandate.name, label: mandate.label || mandate.name } + ), { title: t('Unwiderrufliches Löschen'), confirmLabel: t('Dauerhaft löschen'), variant: 'danger', placeholder: mandate.name }, ); if (entered === null) return; if (entered !== mandate.name) { - showWarning(t('Abgebrochen'), t('Der eingegebene Name stimmt nicht überein.')); + showWarning(t('Abgebrochen'), t('Das eingegebene Kurzzeichen stimmt nicht überein.')); return; } const ok = await handleHardDelete(mandate.id, entered); if (ok) { - showSuccess(t('Gelöscht'), t('Mandant "{name}" wurde endgültig gelöscht.', { name: mandate.name })); + showSuccess( + t('Gelöscht'), + t('Mandant «{name}» wurde endgültig gelöscht.', { name: mandate.label || mandate.name }) + ); } }; @@ -190,7 +199,13 @@ export const AdminMandatesPage: React.FC = () => {

{t('Mandanten')}

-

{t('Verwalten Sie alle Mandanten im')}

+

+ {t('Verwalten Sie alle Mandanten im')} + {' '} + {t( + 'Der Volle Name erscheint in der Oberfläche; das Kurzzeichen ist systemweit eindeutig und dient Referenzierung und Bestätigungsabfragen.' + )} +

)} diff --git a/src/pages/admin/AdminUserAccessOverviewPage.tsx b/src/pages/admin/AdminUserAccessOverviewPage.tsx index 65c70d0..f9ce0cc 100644 --- a/src/pages/admin/AdminUserAccessOverviewPage.tsx +++ b/src/pages/admin/AdminUserAccessOverviewPage.tsx @@ -11,6 +11,7 @@ import api from '../../api'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { mandateDisplayLineLabelThenSlug } from '../../utils/mandateDisplayUtils'; interface UserOption { id: string; @@ -18,6 +19,7 @@ interface UserOption { email: string; fullName: string; isSysAdmin: boolean; + isPlatformAdmin: boolean; enabled: boolean; } @@ -60,14 +62,6 @@ interface MandateInfo { }[]; } -function _mandateNameLine(mandate: MandateInfo): string { - const label = mandate.label?.trim(); - if (label) { - return `${mandate.name} (${label})`; - } - return mandate.name; -} - function _roleDescriptionLine(role: RoleInfo): string { return role.description?.trim() || ''; } @@ -75,6 +69,7 @@ function _roleDescriptionLine(role: RoleInfo): string { interface UserAccessOverview { user: UserOption; isSysAdmin: boolean; + isPlatformAdmin: boolean; sysAdminNote?: string; roles: RoleInfo[]; mandates: MandateInfo[]; @@ -201,7 +196,13 @@ export const AdminUserAccessOverviewPage: React.FC = () => { {overview.isSysAdmin && (
- {overview.sysAdminNote || t('Dieser Benutzer ist SysAdmin und hat vollen Systemzugriff.')} + {overview.sysAdminNote || t('Dieser Benutzer ist Systemadmin (Infrastruktur-Operator) und hat vollen Datenzugriff (RBAC-Bypass).')} +
+ )} + {overview.isPlatformAdmin && ( +
+ + {t('Dieser Benutzer ist Plattformadmin und kann mandantsübergreifend User, Mandate und RBAC-Regeln verwalten (kein RBAC-Bypass).')}
)} @@ -223,7 +224,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => { ) : ( )} - {_mandateNameLine(mandate)} + {mandateDisplayLineLabelThenSlug(mandate)} {t('{r} Mandantenrolle(n) · {i} Feature-Instanz(en)', { r: mandateRoles.length, @@ -623,6 +624,7 @@ export const AdminUserAccessOverviewPage: React.FC = () => { ))} @@ -668,6 +670,14 @@ export const AdminUserAccessOverviewPage: React.FC = () => { )} + {overview.isPlatformAdmin && ( + <> + | + + {t('PlatformAdmin')} + + + )}
{/* Tabs */} diff --git a/src/pages/admin/AdminUserMandatesPage.tsx b/src/pages/admin/AdminUserMandatesPage.tsx index 55c0fb7..2ff9715 100644 --- a/src/pages/admin/AdminUserMandatesPage.tsx +++ b/src/pages/admin/AdminUserMandatesPage.tsx @@ -15,6 +15,7 @@ import api from '../../api'; import styles from './Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils'; export const AdminUserMandatesPage: React.FC = () => { const { t } = useLanguage(); @@ -250,11 +251,6 @@ export const AdminUserMandatesPage: React.FC = () => { setEditingUser(user); }; - // Get mandate name - const getMandateName = (mandate: Mandate) => { - return mandate.label || mandate.name || mandate.id; - }; - if (error && !selectedMandateId) { return (
@@ -295,7 +291,7 @@ export const AdminUserMandatesPage: React.FC = () => { {mandates.map(m => ( ))} diff --git a/src/pages/admin/AdminUsersPage.tsx b/src/pages/admin/AdminUsersPage.tsx index 068cda7..88b3881 100644 --- a/src/pages/admin/AdminUsersPage.tsx +++ b/src/pages/admin/AdminUsersPage.tsx @@ -11,9 +11,12 @@ import { FormGeneratorTable } from '../../components/FormGenerator/FormGenerator import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FaPlus, FaSync, FaKey, FaEnvelopeOpenText, FaUserShield } from 'react-icons/fa'; import styles from './Admin.module.css'; +import { getUserDataCache } from '../../utils/userCache'; import { useLanguage } from '../../providers/language/LanguageContext'; +const _PRIVILEGED_FLAGS = ['isSysAdmin', 'isPlatformAdmin'] as const; + interface User { id: string; username: string; @@ -21,6 +24,7 @@ interface User { fullName: string; enabled: boolean; isSysAdmin?: boolean; + isPlatformAdmin?: boolean; [key: string]: any; } @@ -115,17 +119,41 @@ export const AdminUsersPage: React.FC = () => { await handleSendPasswordLink(user.id); }; - // Form attributes from backend - filter for create/edit forms - const formAttributes = useMemo(() => { + // Privileged-flag gating mirrors the backend rules in routeDataUsers.update_user + // and create_user: only a Platform-Admin may set isSysAdmin / isPlatformAdmin, + // and even then never on themselves (Self-Protection). + const currentUserCache = getUserDataCache(); + const callerIsPlatformAdmin = currentUserCache?.isPlatformAdmin === true; + const callerId = currentUserCache?.id; + + const _buildFormAttributes = (mode: 'create' | 'edit', targetUserId?: string) => { const excludedFields = ['id', 'hashedPassword', 'authenticationAuthority']; + const isSelfEdit = mode === 'edit' && targetUserId !== undefined && targetUserId === callerId; + // Caller may flip flags only when PlatformAdmin AND not editing themselves. + const flagsEditable = callerIsPlatformAdmin && !isSelfEdit; + return (attributes || []) .filter(attr => !excludedFields.includes(attr.name)) - .map(attr => ({ - ...attr, - // Mark username as readonly for edit mode (will be handled by FormGeneratorForm) - editable: attr.name === 'username' ? false : attr.editable, - })); - }, [attributes]); + .map(attr => { + if (_PRIVILEGED_FLAGS.includes(attr.name as any) && !flagsEditable) { + return { ...attr, editable: false }; + } + if (attr.name === 'username') { + return { ...attr, editable: false }; + } + return attr; + }); + }; + + const formAttributesCreate = useMemo( + () => _buildFormAttributes('create'), + [attributes, callerIsPlatformAdmin], + ); + + const formAttributesEdit = useMemo( + () => _buildFormAttributes('edit', editingUser?.id), + [attributes, callerIsPlatformAdmin, callerId, editingUser?.id], + ); if (error) { return ( @@ -242,14 +270,14 @@ export const AdminUsersPage: React.FC = () => {
- {formAttributes.length === 0 ? ( + {formAttributesCreate.length === 0 ? (
{t('Lade Formular')}
) : ( setShowCreateModal(false)} @@ -276,14 +304,14 @@ export const AdminUsersPage: React.FC = () => {
- {formAttributes.length === 0 ? ( + {formAttributesEdit.length === 0 ? (
{t('Lade Formular')}
) : ( = ({ instan ]; return ( -
-
e.stopPropagation()}> +
+

{instance.label}

diff --git a/src/pages/admin/wizards/AdminInvitationWizardPage.tsx b/src/pages/admin/wizards/AdminInvitationWizardPage.tsx index 483a801..d1c35c0 100644 --- a/src/pages/admin/wizards/AdminInvitationWizardPage.tsx +++ b/src/pages/admin/wizards/AdminInvitationWizardPage.tsx @@ -16,6 +16,7 @@ import { useToast } from '../../../contexts/ToastContext'; import styles from '../Admin.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils'; type InviteType = 'mandate' | 'featureInstance'; @@ -114,9 +115,7 @@ export const AdminInvitationWizardPage: React.FC = () => { // HELPERS // ========================================================================== - const getMandateName = (m: Mandate): string => { - return m.label || m.name || m.id; - }; + const getMandateName = (m: Mandate): string => mandateDisplayLabel(m); // ========================================================================== // DATA LOADING diff --git a/src/pages/admin/wizards/AdminMandateWizardPage.tsx b/src/pages/admin/wizards/AdminMandateWizardPage.tsx index ff239cc..4c6e335 100644 --- a/src/pages/admin/wizards/AdminMandateWizardPage.tsx +++ b/src/pages/admin/wizards/AdminMandateWizardPage.tsx @@ -15,13 +15,14 @@ import { import { useToast } from '../../../contexts/ToastContext'; import { useApiRequest } from '../../../hooks/useApi'; import { useMandateFormAttributes } from '../../../hooks/useMandates'; -import { createMandate } from '../../../api/mandateApi'; +import { createMandate, type MandateCreateData } from '../../../api/mandateApi'; import { updateSettingsAdmin } from '../../../api/billingApi'; import { splitMandateAndBillingFromForm } from '../../../utils/mandateBillingFormMerge'; import { FormGeneratorForm } from '../../../components/FormGenerator/FormGeneratorForm'; import styles from '../Admin.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils'; const TOTAL_STEPS = 4; @@ -103,9 +104,8 @@ export const AdminMandateWizardPage: React.FC = () => { // HELPERS // ───────────────────────────────────────────────────────────────────────── - const getMandateName = (m: Mandate | Record): string => { - return m.label || m.name || m.id; - }; + const getMandateName = (m: Mandate | Record): string => + mandateDisplayLabel(m as { label?: string | null; name?: string | null; id?: string }); const getFeatureLabel = (code: string): string => { const f = features.find(feat => feat.code === code); @@ -216,9 +216,10 @@ export const AdminMandateWizardPage: React.FC = () => { setError(null); try { const { mandatePayload, billingUpdate } = splitMandateAndBillingFromForm(data); - const body = { - ...mandatePayload, - enabled: mandatePayload.enabled !== undefined ? mandatePayload.enabled : true, + const body: MandateCreateData = { + ...(mandatePayload as Record), + label: String(mandatePayload.label ?? '').trim(), + enabled: typeof mandatePayload.enabled === 'boolean' ? mandatePayload.enabled : true, }; const created = await createMandate(request, body); let billingSaved = false; diff --git a/src/pages/admin/wizards/FeatureInstanceWizard.module.css b/src/pages/admin/wizards/FeatureInstanceWizard.module.css index 549b176..f7729d3 100644 --- a/src/pages/admin/wizards/FeatureInstanceWizard.module.css +++ b/src/pages/admin/wizards/FeatureInstanceWizard.module.css @@ -1,5 +1,100 @@ .modal { - max-width: 520px; + max-width: 640px; +} + +/* Step 1 — selectable cards (mandate / feature) */ + +.fieldLabel { + display: block; + font-size: 0.8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.required { + color: var(--primary-color, #f25843); + margin-left: 0.25rem; +} + +.cardGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + gap: 0.5rem; +} + +.cardButton { + display: flex; + align-items: center; + justify-content: center; + text-align: center; + padding: 0.75rem 0.875rem; + border-radius: 8px; + border: 1px solid var(--border-color); + background: var(--bg-secondary, rgba(255, 255, 255, 0.03)); + color: var(--text-primary); + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + min-height: 44px; +} + +.cardButton:hover { + border-color: rgba(242, 88, 67, 0.5); + background: rgba(242, 88, 67, 0.06); +} + +.cardButton:focus-visible { + outline: 2px solid var(--primary-color, #f25843); + outline-offset: 2px; +} + +.cardButtonActive { + border-color: rgba(242, 88, 67, 0.7); + background: rgba(242, 88, 67, 0.18); + color: var(--primary-color, #f25843); + box-shadow: 0 0 0 1px rgba(242, 88, 67, 0.3) inset; +} + +.cardButton:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.textInput { + width: 100%; + padding: 0.55rem 0.75rem; + border-radius: 6px; + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + font-size: 0.9rem; + box-sizing: border-box; +} + +.textInput:focus { + outline: none; + border-color: var(--primary-color, #f25843); +} + +.fieldError { + margin-top: 0.35rem; + font-size: 0.8rem; + color: var(--primary-color, #f25843); +} + +.fieldGroup { + display: flex; + flex-direction: column; +} + +.fieldHint { + margin-top: 0.35rem; + font-size: 0.78rem; + color: var(--text-secondary); } .steps { diff --git a/src/pages/admin/wizards/FeatureInstanceWizard.tsx b/src/pages/admin/wizards/FeatureInstanceWizard.tsx index ef9ab11..77bae34 100644 --- a/src/pages/admin/wizards/FeatureInstanceWizard.tsx +++ b/src/pages/admin/wizards/FeatureInstanceWizard.tsx @@ -6,7 +6,6 @@ import React, { useState, useMemo } from 'react'; import { useFeatureAccess } from '../../../hooks/useFeatureAccess'; -import { FormGeneratorForm, type AttributeDefinition } from '../../../components/FormGenerator/FormGeneratorForm'; import { useToast } from '../../../contexts/ToastContext'; import api from '../../../api'; import type { Mandate } from '../../../hooks/useUserMandates'; @@ -15,9 +14,10 @@ import styles from '../Admin.module.css'; import wizardStyles from './FeatureInstanceWizard.module.css'; import { useLanguage } from '../../../providers/language/LanguageContext'; +import { mandateDisplayLabel } from '../../../utils/mandateDisplayUtils'; function getMandateName(m: Mandate): string { - return m.label || m.name || m.id; + return mandateDisplayLabel(m); } export interface FeatureInstanceWizardProps { @@ -55,45 +55,25 @@ export const FeatureInstanceWizard: React.FC = ({ ma const [mandateUsers, setMandateUsers] = useState>([]); const [instanceRoles, setInstanceRoles] = useState>([]); const [selectedUserRoles, setSelectedUserRoles] = useState>([]); + const [labelTouched, setLabelTouched] = useState(false); - const featureOptions = useMemo( - () => features.map((f) => ({ value: f.code, label: f.label || f.code })), - [features] - ); - const mandateOptions = useMemo( - () => mandates.map((m) => ({ value: m.id, label: getMandateName(m) })), - [mandates] - ); + const trimmedLabel = label.trim(); + const labelMissing = trimmedLabel.length === 0; + const canSubmitStep1 = !!mandateId && !!featureCode && !labelMissing && !submitting; - const createFields: AttributeDefinition[] = useMemo( - () => [ - { name: 'mandateId', label: t('Mandant'), type: 'enum' as const, required: true, options: mandateOptions }, - { name: 'featureCode', label: t('Feature'), type: 'enum' as const, required: true, options: featureOptions }, - { name: 'label', label: t('Bezeichnung'), type: 'string' as const, required: true, editable: true }, - { name: 'enabled', label: t('Aktiv'), type: 'boolean' as const, required: false, editable: true }, - ], - [mandateOptions, featureOptions] - ); - - const handleStep1Submit = async (data: { - mandateId: string; - featureCode: string; - label: string; - enabled?: boolean; - }) => { + const handleStep1Submit = async () => { + setLabelTouched(true); + if (!canSubmitStep1) return; setSubmitting(true); try { - const result = await createInstance(data.mandateId, { - featureCode: data.featureCode, - label: data.label, - enabled: data.enabled !== false, - copyTemplateRoles: copyTemplateRoles, + const result = await createInstance(mandateId, { + featureCode, + label: trimmedLabel, + enabled, + copyTemplateRoles, }); if (result.success && result.data) { - setMandateId(data.mandateId); - setFeatureCode(data.featureCode); - setLabel(data.label); - setEnabled(data.enabled !== false); + setLabel(trimmedLabel); setCreatedInstanceId(result.data.id); setStep(1); } else { @@ -165,8 +145,8 @@ export const FeatureInstanceWizard: React.FC = ({ ma const currentStepId = steps[step]?.id; return ( -
-
e.stopPropagation()}> +
+

{t('Neue Feature-Instanz')}

+ ); + })} +
+ )} +
+ +
+ + {t('Feature')}* + + {features.length === 0 ? ( +

{t('Keine Features verfügbar')}

+ ) : ( +
+ {features.map((f) => { + const isActive = featureCode === f.code; + return ( + + ); + })} +
+ )} +
+ +
+ + setLabel(e.target.value)} + onBlur={() => setLabelTouched(true)} + placeholder={t('z. B. Vertrieb DE')} + autoComplete="off" + /> + {labelTouched && labelMissing && ( +

{t('Bezeichnung ist erforderlich.')}

+ )} +
+ + + + +
+ + +
)} diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index af1638d..79d6199 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -142,6 +142,24 @@ export const FilesPage: React.FC = () => { } }, [updateTreeFileNode, refreshTreeFiles, _tableRefetch]); + const _handleFolderScopeChange = useCallback(async (folderId: string, newScope: string) => { + try { + await api.patch(`/api/files/folders/${folderId}/scope`, { scope: newScope }); + await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]); + } catch (err) { + console.error('Failed to update folder scope:', err); + } + }, [refreshFolders, refreshTreeFiles, _tableRefetch]); + + const _handleFolderNeutralizeToggle = useCallback(async (folderId: string, newValue: boolean) => { + try { + await api.patch(`/api/files/folders/${folderId}/neutralize`, { neutralize: newValue }); + await Promise.all([refreshFolders(), refreshTreeFiles(), _tableRefetch()]); + } catch (err) { + console.error('Failed to toggle folder neutralize:', err); + } + }, [refreshFolders, refreshTreeFiles, _tableRefetch]); + // ── Folder nodes for tree (real folders only) ──────────────────────── const folderNodes = useMemo(() => { return folders.map(f => ({ @@ -149,6 +167,8 @@ export const FilesPage: React.FC = () => { name: f.name, parentId: f.parentId ?? null, fileCount: f.fileCount ?? 0, + neutralize: f.neutralize ?? false, + scope: f.scope ?? 'personal', })); }, [folders]); @@ -426,6 +446,8 @@ export const FilesPage: React.FC = () => { onDownloadFolder={handleDownloadFolder} onScopeChange={_handleScopeChange} onNeutralizeToggle={_handleNeutralizeToggle} + onFolderScopeChange={_handleFolderScopeChange} + onFolderNeutralizeToggle={_handleFolderNeutralizeToggle} />
diff --git a/src/pages/billing/BillingAdmin.tsx b/src/pages/billing/BillingAdmin.tsx index 24341b0..d0a835e 100644 --- a/src/pages/billing/BillingAdmin.tsx +++ b/src/pages/billing/BillingAdmin.tsx @@ -17,6 +17,7 @@ import { SubscriptionTab } from './SubscriptionTab'; import api from '../../api'; import { getUserDataCache } from '../../utils/userCache'; import { useLanguage } from '../../providers/language/LanguageContext'; +import { mandateDisplayLabel } from '../../utils/mandateDisplayUtils'; import styles from './Billing.module.css'; type AdminTabType = 'subscription' | 'settings' | 'credit'; @@ -28,9 +29,6 @@ const _formatCurrency = (amount: number) => { }).format(amount); }; -const _mandateDisplayLabel = (m: UserMandateRow): string => { - return m.label || m.name || m.id; -}; // ============================================================================ // MANDATE SELECTOR @@ -62,7 +60,7 @@ const MandateSelector: React.FC = ({ {mandates.map(mandate => ( ))} @@ -446,7 +444,7 @@ export const BillingAdmin: React.FC = () => { const { t } = useLanguage(); const [searchParams, setSearchParams] = useSearchParams(); const { user: currentUser } = useCurrentUser(); - const isSysAdmin = currentUser?.isSysAdmin === true; + const isSysAdmin = currentUser?.isPlatformAdmin === true; const [selectedMandateId, setSelectedMandateId] = useState( searchParams.get('mandate') || null diff --git a/src/pages/views/neutralization/NeutralizationView.tsx b/src/pages/views/neutralization/NeutralizationView.tsx index b7486e7..2ee5845 100644 --- a/src/pages/views/neutralization/NeutralizationView.tsx +++ b/src/pages/views/neutralization/NeutralizationView.tsx @@ -646,8 +646,8 @@ const PlaygroundTab: React.FC = () => {
{browseTarget && ( -
-
e.stopPropagation()}> +
+

{browseTarget === 'source' ? t('SharePoint-Quellordner durchsuchen') diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx index cc41cef..174c50e 100644 --- a/src/pages/views/realestate/RealEstateParcelsView.tsx +++ b/src/pages/views/realestate/RealEstateParcelsView.tsx @@ -211,8 +211,8 @@ export const RealEstateParcelsView: React.FC = () => {

{(editingParcel || isCreateMode) && ( -
-
e.stopPropagation()}> +
+

{isCreateMode ? t('Neue Parzelle') : t('Parzelle bearbeiten')} diff --git a/src/pages/views/realestate/RealEstateProjectsView.tsx b/src/pages/views/realestate/RealEstateProjectsView.tsx index 1a83e73..07f425f 100644 --- a/src/pages/views/realestate/RealEstateProjectsView.tsx +++ b/src/pages/views/realestate/RealEstateProjectsView.tsx @@ -174,8 +174,8 @@ export const RealEstateProjectsView: React.FC = () => {

{(editingProject || isCreateMode) && ( -
-
e.stopPropagation()}> +
+

{isCreateMode ? t('Neues Projekt') : t('Projekt bearbeiten')}

diff --git a/src/pages/views/teamsbot/TeamsbotSettingsView.tsx b/src/pages/views/teamsbot/TeamsbotSettingsView.tsx index ed2ca21..6bd6ac3 100644 --- a/src/pages/views/teamsbot/TeamsbotSettingsView.tsx +++ b/src/pages/views/teamsbot/TeamsbotSettingsView.tsx @@ -2,6 +2,7 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import { useCurrentInstance } from '../../../hooks/useCurrentInstance'; import * as teamsbotApi from '../../../api/teamsbotApi'; import type { TeamsbotConfig, ConfigUpdateRequest, VoiceOption } from '../../../api/teamsbotApi'; +import type { VoiceLanguage } from '../../../api/voiceCatalogApi'; import { FaPlay, FaSpinner } from 'react-icons/fa'; import styles from './Teamsbot.module.css'; @@ -36,8 +37,8 @@ export const TeamsbotSettingsView: React.FC = () => { // Form state const [formData, setFormData] = useState({}); - // Dynamic voice data from Google TTS API - const [languages, setLanguages] = useState([]); + // Voice catalog (single source of truth) + dynamic voices for the selected language + const [languages, setLanguages] = useState([]); const [voices, setVoices] = useState([]); const [loadingVoices, setLoadingVoices] = useState(false); @@ -247,19 +248,13 @@ export const TeamsbotSettingsView: React.FC = () => { value={formData.language || 'de-DE'} onChange={(e) => _handleLanguageChange(e.target.value)} > - {languages.length > 0 ? ( - languages.map((langCode, idx) => ( - - )) - ) : ( - <> - - - - - )} + {languages.map(lang => ( + + ))} - Sprache fuer Captions und Sprachausgabe ({languages.length} Sprachen verfuegbar) + {t('Sprache für Captions und Sprachausgabe')} ({languages.length} {t('Sprachen verfügbar')})
diff --git a/src/pages/views/trustee/TrusteeDocumentsView.tsx b/src/pages/views/trustee/TrusteeDocumentsView.tsx index 2e52944..bb3b5df 100644 --- a/src/pages/views/trustee/TrusteeDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteeDocumentsView.tsx @@ -266,8 +266,8 @@ export const TrusteeDocumentsView: React.FC = () => { {/* Create/Edit Modal */} {(editingDocument || isCreateMode) && ( -
-
e.stopPropagation()}> +
+

{isCreateMode ? t('Neues Dokument') : t('Dokument bearbeiten')} diff --git a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx index d15b070..42557a5 100644 --- a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx @@ -213,8 +213,8 @@ export const TrusteePositionDocumentsView: React.FC = () => { {/* Create Modal */} {isCreateMode && ( -
-
e.stopPropagation()}> +
+

{t('Neue Verknüpfung erstellen')}