From bc091c399cef2a0245cfa80855854357ff795de2 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 24 Mar 2026 14:16:45 +0100 Subject: [PATCH 01/11] unified data - step 1 --- src/api/authApi.ts | 8 +- src/api/storeApi.ts | 46 +++- src/components/OnboardingAssistant.tsx | 206 ++++++++++++++++++ src/components/OnboardingWizard.tsx | 119 ++++++++++ .../UiComponents/Messages/MessagesTypes.ts | 6 + .../UnifiedDataBar/ChatsTab.module.css | 134 ++++++++++++ src/components/UnifiedDataBar/ChatsTab.tsx | 186 ++++++++++++++++ .../UnifiedDataBar/FilesTab.module.css | 94 ++++++++ src/components/UnifiedDataBar/FilesTab.tsx | 134 ++++++++++++ .../UnifiedDataBar/SourcesTab.module.css | 11 + src/components/UnifiedDataBar/SourcesTab.tsx | 24 ++ .../UnifiedDataBar/UnifiedDataBar.module.css | 60 +++++ .../UnifiedDataBar/UnifiedDataBar.tsx | 69 ++++++ src/components/UnifiedDataBar/index.ts | 6 + .../UnifiedDataBar/useUdlContext.ts | 23 ++ src/hooks/useAuthentication.ts | 1 + src/hooks/useStore.ts | 42 +++- src/pages/Login.module.css | 44 ++++ src/pages/Login.tsx | 39 +++- src/pages/Register.tsx | 37 +++- src/pages/Store.module.css | 111 +++++++++- src/pages/Store.tsx | 146 ++++++++++--- .../commcoach/CommcoachDossierView.module.css | 51 ++++- .../views/commcoach/CommcoachDossierView.tsx | 150 ++++--------- .../views/commcoach/CommcoachSettingsView.tsx | 16 ++ src/pages/views/workspace/ChatStream.tsx | 26 +++ .../views/workspace/NeutralizationPanel.tsx | 191 ++++++++++++++++ src/pages/views/workspace/WorkspaceInput.tsx | 18 +- src/pages/views/workspace/WorkspacePage.tsx | 81 +++---- .../views/workspace/WorkspaceSettingsPage.tsx | 7 +- 30 files changed, 1876 insertions(+), 210 deletions(-) create mode 100644 src/components/OnboardingAssistant.tsx create mode 100644 src/components/OnboardingWizard.tsx create mode 100644 src/components/UnifiedDataBar/ChatsTab.module.css create mode 100644 src/components/UnifiedDataBar/ChatsTab.tsx create mode 100644 src/components/UnifiedDataBar/FilesTab.module.css create mode 100644 src/components/UnifiedDataBar/FilesTab.tsx create mode 100644 src/components/UnifiedDataBar/SourcesTab.module.css create mode 100644 src/components/UnifiedDataBar/SourcesTab.tsx create mode 100644 src/components/UnifiedDataBar/UnifiedDataBar.module.css create mode 100644 src/components/UnifiedDataBar/UnifiedDataBar.tsx create mode 100644 src/components/UnifiedDataBar/index.ts create mode 100644 src/components/UnifiedDataBar/useUdlContext.ts create mode 100644 src/pages/views/workspace/NeutralizationPanel.tsx diff --git a/src/api/authApi.ts b/src/api/authApi.ts index 8343584..30ce6bb 100644 --- a/src/api/authApi.ts +++ b/src/api/authApi.ts @@ -27,6 +27,8 @@ export interface RegisterData { language?: string; enabled?: boolean; privilege?: string; + registrationType?: 'personal' | 'company'; + companyName?: string; } export interface RegisterRequest { @@ -40,6 +42,8 @@ export interface RegisterRequest { authenticationAuthority: string; }; frontendUrl: string; + registrationType?: string; + companyName?: string; } export interface PasswordResetRequestResponse { @@ -172,7 +176,9 @@ export async function registerApi(registerData: RegisterData): Promise; icon: string; description: Record; - isActive: boolean; + instances: StoreFeatureInstance[]; canActivate: boolean; - instanceId: string | null; } export interface StoreActivateResponse { @@ -31,17 +38,44 @@ export interface StoreDeactivateResponse { deactivated: boolean; } +export interface UserMandate { + id: string; + name: string; + label: string; + mandateType: string; +} + +export interface SubscriptionInfo { + plan: string | null; + status: string | null; + maxDataVolumeMB: number | null; + maxFeatureInstances: number | null; + currentFeatureInstances: number; + trialEndsAt: string | null; +} + export async function fetchStoreFeatures(): Promise { const response = await api.get('/api/store/features'); return response.data; } -export async function activateStoreFeature(featureCode: string): Promise { - const response = await api.post('/api/store/activate', { featureCode }); +export async function fetchUserMandates(): Promise { + const response = await api.get('/api/store/mandates'); return response.data; } -export async function deactivateStoreFeature(featureCode: string): Promise { - const response = await api.post('/api/store/deactivate', { featureCode }); +export async function fetchSubscriptionInfo(mandateId?: string): Promise { + const params = mandateId ? { mandateId } : {}; + const response = await api.get('/api/store/subscription-info', { params }); + return response.data; +} + +export async function activateStoreFeature(featureCode: string, mandateId?: string): Promise { + const response = await api.post('/api/store/activate', { featureCode, mandateId }); + return response.data; +} + +export async function deactivateStoreFeature(featureCode: string, mandateId: string, instanceId: string): Promise { + const response = await api.post('/api/store/deactivate', { featureCode, mandateId, instanceId }); return response.data; } diff --git a/src/components/OnboardingAssistant.tsx b/src/components/OnboardingAssistant.tsx new file mode 100644 index 0000000..97bd5b0 --- /dev/null +++ b/src/components/OnboardingAssistant.tsx @@ -0,0 +1,206 @@ +import React, { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import api from '../api'; + +interface OnboardingStep { + id: string; + label: string; + description: string; + completed: boolean; + action?: () => void; +} + +interface OnboardingAssistantProps { + instanceId?: string; + mandateId?: string; + featureCode?: string; + onDismiss?: () => void; +} + +const _DISMISS_KEY = 'onboarding_dismissed'; +const _DISMISS_COOLDOWN_MS = 24 * 60 * 60 * 1000; + +const OnboardingAssistant: React.FC = ({ + instanceId, + mandateId, + featureCode, + onDismiss, +}) => { + const navigate = useNavigate(); + const [dismissed, setDismissed] = useState(false); + const [steps, setSteps] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + try { + const dismissedAt = localStorage.getItem(_DISMISS_KEY); + if (dismissedAt && Date.now() - parseInt(dismissedAt) < _DISMISS_COOLDOWN_MS) { + setDismissed(true); + } + } catch { /* ignore */ } + }, []); + + useEffect(() => { + _checkOnboardingState(); + }, [instanceId, mandateId]); + + const _checkOnboardingState = async () => { + setLoading(true); + try { + const onboardingSteps: OnboardingStep[] = []; + + let hasMandate = !!mandateId; + if (!hasMandate) { + try { + const mandatesRes = await api.get('/api/store/mandates'); + hasMandate = (mandatesRes.data || []).length > 0; + } catch { /* ignore */ } + } + onboardingSteps.push({ + id: 'mandate', + label: 'Mandant einrichten', + description: hasMandate ? 'Dein Mandant ist eingerichtet.' : 'Richte deinen Mandanten ein, um loszulegen.', + completed: hasMandate, + action: hasMandate ? undefined : () => navigate('/store'), + }); + + let hasInstances = !!instanceId; + if (!hasInstances) { + try { + const storeRes = await api.get('/api/store/features'); + const features = storeRes.data || []; + hasInstances = features.some((f: any) => f.instances && f.instances.length > 0); + } catch { /* ignore */ } + } + onboardingSteps.push({ + id: 'feature', + label: 'Erstes Feature aktivieren', + description: hasInstances ? 'Du hast aktive Features.' : 'Aktiviere dein erstes Feature im Store.', + completed: hasInstances, + action: hasInstances ? undefined : () => navigate('/store'), + }); + + let hasData = false; + if (instanceId) { + try { + const filesRes = await api.get(`/api/workspace/${instanceId}/files`); + const files = filesRes.data?.data || filesRes.data || []; + hasData = files.length > 0; + } catch { /* ignore */ } + } + onboardingSteps.push({ + id: 'data', + label: 'Erste Datenquelle einbinden', + description: hasData ? 'Du hast Daten im Workspace.' : 'Lade eine Datei hoch oder verbinde eine Datenquelle.', + completed: hasData, + }); + + let hasChats = false; + if (instanceId) { + try { + const chatsRes = await api.get(`/api/workspace/${instanceId}/workflows`); + const chats = chatsRes.data?.data || chatsRes.data || []; + hasChats = chats.length > 0; + } catch { /* ignore */ } + } + onboardingSteps.push({ + id: 'chat', + label: 'Ersten AI-Chat starten', + description: hasChats ? 'Du hast bereits Chats.' : 'Starte deinen ersten Chat mit dem AI-Assistenten.', + completed: hasChats, + }); + + setSteps(onboardingSteps); + + if (onboardingSteps.every(s => s.completed)) { + setDismissed(true); + } + } catch (err) { + console.error('Onboarding check failed:', err); + } finally { + setLoading(false); + } + }; + + const _handleDismiss = () => { + setDismissed(true); + onDismiss?.(); + try { + localStorage.setItem(_DISMISS_KEY, Date.now().toString()); + } catch { /* ignore */ } + }; + + if (dismissed || loading) return null; + + const completedCount = steps.filter(s => s.completed).length; + if (completedCount === steps.length) return null; + + return ( +
+
+
+

Willkommen bei PowerOn

+

+ {completedCount} von {steps.length} Schritten abgeschlossen +

+
+ +
+ +
+ {steps.map((step) => ( +
+ ))} +
+ +
+ {steps.map((step) => ( +
+ + {step.completed ? '\u2713' : '\u25CB'} + +
+
+ {step.label} +
+
+ {step.description} +
+
+ {step.action && !step.completed && ( + {'\u2192'} + )} +
+ ))} +
+
+ ); +}; + +export default OnboardingAssistant; diff --git a/src/components/OnboardingWizard.tsx b/src/components/OnboardingWizard.tsx new file mode 100644 index 0000000..5815cbb --- /dev/null +++ b/src/components/OnboardingWizard.tsx @@ -0,0 +1,119 @@ +import React, { useState } from 'react'; +import api from '../api'; + +interface OnboardingWizardProps { + onComplete: () => void; + onDismiss: () => void; +} + +const OnboardingWizard: React.FC = ({ onComplete, onDismiss }) => { + const [mandateType, setMandateType] = useState<'personal' | 'company'>('personal'); + const [companyName, setCompanyName] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const _handleSubmit = async () => { + setLoading(true); + setError(null); + try { + await api.post('/api/local/onboarding', { + mandateType, + companyName: mandateType === 'company' ? companyName : undefined, + }); + onComplete(); + } catch (err: any) { + setError(err?.response?.data?.detail || 'Fehler bei der Einrichtung'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Willkommen bei PowerOn

+

+ Wie möchtest du PowerOn nutzen? +

+ +
+ + + +
+ + {mandateType === 'company' && ( +
+ + setCompanyName(e.target.value)} + placeholder="Name des Unternehmens" + style={{ + width: '100%', padding: '10px 12px', borderRadius: '6px', + border: '1px solid var(--border, #d1d5db)', fontSize: '1rem', + boxSizing: 'border-box', + }} + /> +
+ )} + + {error &&

{error}

} + +
+ + +
+
+
+ ); +}; + +export default OnboardingWizard; diff --git a/src/components/UiComponents/Messages/MessagesTypes.ts b/src/components/UiComponents/Messages/MessagesTypes.ts index 7aa81b3..04ac70a 100644 --- a/src/components/UiComponents/Messages/MessagesTypes.ts +++ b/src/components/UiComponents/Messages/MessagesTypes.ts @@ -15,6 +15,12 @@ export interface MessageDocument { taskNumber: number; actionNumber: number; actionId: string; + documentName?: string; + validationMetadata?: { + neutralized?: boolean; + skipped?: boolean; + [key: string]: unknown; + }; } /** diff --git a/src/components/UnifiedDataBar/ChatsTab.module.css b/src/components/UnifiedDataBar/ChatsTab.module.css new file mode 100644 index 0000000..5118b5b --- /dev/null +++ b/src/components/UnifiedDataBar/ChatsTab.module.css @@ -0,0 +1,134 @@ +.chatsTab { + display: flex; + flex-direction: column; + gap: 8px; +} + +.toolbar { + display: flex; + gap: 6px; + align-items: center; +} + +.search { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--border-color, #d1d5db); + border-radius: 6px; + font-size: 0.85rem; + background: var(--bg-input, #fff); + color: var(--text-primary, #111); +} + +.modeToggle { + padding: 6px 8px; + border: 1px solid var(--border-color, #d1d5db); + border-radius: 6px; + background: transparent; + cursor: pointer; + font-size: 0.85rem; +} + +.modeActive { + background: var(--bg-active, #eef2ff); +} + +.loading { + padding: 16px; + text-align: center; + color: var(--text-secondary, #6b7280); +} + +.flatList, +.tree { + display: flex; + flex-direction: column; +} + +.chatItem { + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.85rem; +} + +.chatItem:hover { + background: var(--bg-hover, rgba(0, 0, 0, 0.04)); +} + +.chatLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.chatDate { + font-size: 0.75rem; + color: var(--text-secondary, #9ca3af); + flex-shrink: 0; + margin-left: 8px; +} + +.treeGroup { + margin-bottom: 2px; +} + +.treeGroupHeader { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + border-radius: 6px; + cursor: pointer; + font-weight: 500; + font-size: 0.85rem; +} + +.treeGroupHeader:hover { + background: var(--bg-hover, rgba(0, 0, 0, 0.04)); +} + +.treeGroupCurrent { + color: var(--accent, #4f46e5); +} + +.treeArrow { + font-size: 0.7rem; + width: 12px; +} + +.treeGroupLabel { + flex: 1; +} + +.treeGroupCount { + font-size: 0.75rem; + color: var(--text-secondary, #9ca3af); + background: var(--bg-badge, #f3f4f6); + padding: 1px 6px; + border-radius: 10px; +} + +.treeChildren { + padding-left: 20px; +} + +@media (prefers-color-scheme: dark) { + .search { + background: var(--bg-input-dark, #1f2937); + border-color: var(--border-dark, #374151); + color: #f3f4f6; + } + .chatItem:hover, + .treeGroupHeader:hover { + background: rgba(255, 255, 255, 0.05); + } + .treeGroupCount { + background: #374151; + color: #9ca3af; + } +} diff --git a/src/components/UnifiedDataBar/ChatsTab.tsx b/src/components/UnifiedDataBar/ChatsTab.tsx new file mode 100644 index 0000000..9391756 --- /dev/null +++ b/src/components/UnifiedDataBar/ChatsTab.tsx @@ -0,0 +1,186 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import type { UdbContext } from './UnifiedDataBar'; +import api from '../../api'; +import styles from './ChatsTab.module.css'; + +interface ChatItem { + id: string; + label: string; + updatedAt?: string; + featureInstanceId?: string; + featureCode?: string; +} + +interface ChatGroup { + featureInstanceId: string; + featureLabel: string; + featureCode: string; + chats: ChatItem[]; +} + +interface ChatsTabProps { + context: UdbContext; + onSelectChat?: (chatId: string, featureInstanceId: string) => void; + onDragStart?: (chatId: string, event: React.DragEvent) => void; +} + +const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart }) => { + const [groups, setGroups] = useState([]); + const [flatMode, setFlatMode] = useState(false); + const [search, setSearch] = useState(''); + const [expandedGroups, setExpandedGroups] = useState>(new Set()); + const [loading, setLoading] = useState(true); + + const _loadChats = useCallback(async () => { + setLoading(true); + try { + const response = await api.get(`/api/workspace/${context.instanceId}/workflows`); + const workflows = response.data?.data || response.data || []; + + const groupMap = new Map(); + for (const wf of workflows) { + const fiId = wf.featureInstanceId || context.instanceId; + if (!groupMap.has(fiId)) { + groupMap.set(fiId, { + featureInstanceId: fiId, + featureLabel: wf.featureLabel || wf.featureCode || fiId.slice(0, 8), + featureCode: wf.featureCode || 'workspace', + chats: [], + }); + } + groupMap.get(fiId)!.chats.push({ + id: wf.id, + label: wf.label || wf.name || `Chat ${wf.id.slice(0, 8)}`, + updatedAt: wf.updatedAt || wf.createdAt, + featureInstanceId: fiId, + featureCode: wf.featureCode, + }); + } + + const sorted = Array.from(groupMap.values()); + sorted.forEach(g => + g.chats.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')), + ); + setGroups(sorted); + + if (expandedGroups.size === 0 && sorted.length > 0) { + setExpandedGroups(new Set([context.instanceId])); + } + } catch (err) { + console.error('Failed to load chats:', err); + } finally { + setLoading(false); + } + }, [context.instanceId]); + + useEffect(() => { _loadChats(); }, [_loadChats]); + + const _toggleGroup = (id: string) => { + setExpandedGroups(prev => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + }; + + const _filteredGroups = groups + .map(g => ({ + ...g, + chats: search + ? g.chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase())) + : g.chats, + })) + .filter(g => g.chats.length > 0); + + const _allChats = _filteredGroups + .flatMap(g => g.chats) + .sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')); + + if (loading) return
Lade Chats...
; + + return ( +
+
+ setSearch(e.target.value)} + /> + +
+ + {flatMode ? ( +
+ {_allChats.map((chat) => ( +
onSelectChat?.(chat.id, chat.featureInstanceId || context.instanceId)} + draggable={!!onDragStart} + onDragStart={(e) => { + e.dataTransfer.setData('application/chat-id', chat.id); + e.dataTransfer.setData('text/plain', chat.label); + onDragStart?.(chat.id, e); + }} + > + {chat.label} + {chat.updatedAt && ( + + {new Date(chat.updatedAt).toLocaleDateString()} + + )} +
+ ))} +
+ ) : ( +
+ {_filteredGroups.map((group) => ( +
+
_toggleGroup(group.featureInstanceId)} + > + + {expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'} + + {group.featureLabel} + {group.chats.length} +
+ {expandedGroups.has(group.featureInstanceId) && ( +
+ {group.chats.map((chat) => ( +
onSelectChat?.(chat.id, group.featureInstanceId)} + draggable={!!onDragStart} + onDragStart={(e) => { + e.dataTransfer.setData('application/chat-id', chat.id); + e.dataTransfer.setData('text/plain', chat.label); + onDragStart?.(chat.id, e); + }} + > + {chat.label} +
+ ))} +
+ )} +
+ ))} +
+ )} +
+ ); +}; + +export default ChatsTab; diff --git a/src/components/UnifiedDataBar/FilesTab.module.css b/src/components/UnifiedDataBar/FilesTab.module.css new file mode 100644 index 0000000..a79c04c --- /dev/null +++ b/src/components/UnifiedDataBar/FilesTab.module.css @@ -0,0 +1,94 @@ +.filesTab { + display: flex; + flex-direction: column; + height: 100%; +} + +.loading, +.empty { + padding: 16px; + text-align: center; + color: var(--text-secondary, #6b7280); + font-size: 0.85rem; +} + +.fileList { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + overflow-y: auto; +} + +.fileRow { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + font-size: 0.85rem; +} + +.fileRow:hover { + background: var(--bg-hover, rgba(0, 0, 0, 0.04)); +} + +.fileName { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.fileIcons { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.scopeIcon, +.neutralizeIcon { + border: none; + background: transparent; + cursor: pointer; + font-size: 0.9rem; + padding: 2px 4px; + border-radius: 4px; + opacity: 0.6; + transition: opacity 0.15s; +} + +.scopeIcon:hover, +.neutralizeIcon:hover { + opacity: 1; + background: var(--bg-hover, rgba(0, 0, 0, 0.06)); +} + +.neutralizeActive { + opacity: 1; +} + +.legend { + display: flex; + gap: 12px; + padding: 8px 10px; + border-top: 1px solid var(--border-color, #e5e7eb); + font-size: 0.75rem; + color: var(--text-secondary, #9ca3af); + flex-shrink: 0; + flex-wrap: wrap; +} + +@media (prefers-color-scheme: dark) { + .fileRow:hover { + background: rgba(255, 255, 255, 0.05); + } + .scopeIcon:hover, + .neutralizeIcon:hover { + background: rgba(255, 255, 255, 0.08); + } + .legend { + border-top-color: var(--border-dark, #374151); + } +} diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx new file mode 100644 index 0000000..65183c9 --- /dev/null +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -0,0 +1,134 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import type { UdbContext } from './UnifiedDataBar'; +import api from '../../api'; +import styles from './FilesTab.module.css'; + +interface FileEntry { + id: string; + fileName: string; + mimeType?: string; + scope: string; + neutralize: boolean; + fileSize?: number; +} + +const _SCOPE_ICONS: Record = { + personal: '\uD83D\uDC64', + featureInstance: '\uD83D\uDC65', + mandate: '\uD83C\uDFE2', + global: '\uD83C\uDF10', +}; + +const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate']; + +interface FilesTabProps { + context: UdbContext; + onFileSelect?: (fileId: string) => void; +} + +const FilesTab: React.FC = ({ context, onFileSelect }) => { + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + + const _loadFiles = useCallback(async () => { + setLoading(true); + try { + const response = await api.get(`/api/workspace/${context.instanceId}/files`); + const data = response.data?.data || response.data || []; + setFiles( + data.map((f: any) => ({ + id: f.id, + fileName: f.fileName || f.name || 'unknown', + mimeType: f.mimeType, + scope: f.scope || 'personal', + neutralize: f.neutralize || false, + fileSize: f.fileSize, + })), + ); + } catch (err) { + console.error('Failed to load files:', err); + } finally { + setLoading(false); + } + }, [context.instanceId]); + + useEffect(() => { + _loadFiles(); + }, [_loadFiles]); + + const _cycleScope = async (file: FileEntry) => { + const currentIdx = _SCOPE_CYCLE.indexOf(file.scope); + const nextScope = _SCOPE_CYCLE[(currentIdx + 1) % _SCOPE_CYCLE.length]; + try { + await api.patch(`/api/files/${file.id}/scope`, { scope: nextScope }); + setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, scope: nextScope } : f))); + } catch (err) { + console.error('Failed to update scope:', err); + } + }; + + const _toggleNeutralize = async (file: FileEntry) => { + try { + await api.patch(`/api/files/${file.id}/neutralize`, { neutralize: !file.neutralize }); + setFiles(prev => + prev.map(f => (f.id === file.id ? { ...f, neutralize: !f.neutralize } : f)), + ); + } catch (err) { + console.error('Failed to toggle neutralize:', err); + } + }; + + if (loading) return
Lade Dateien...
; + + return ( +
+ {files.length === 0 ? ( +
Keine Dateien vorhanden
+ ) : ( +
+ {files.map((file) => ( +
onFileSelect?.(file.id)} + > + {file.fileName} +
+ + +
+
+ ))} +
+ )} +
+ {'\uD83D\uDC64'} Pers\u00F6nlich + {'\uD83D\uDC65'} Instanz + {'\uD83C\uDFE2'} Mandant + {'\uD83D\uDD12'} Neutralisiert +
+
+ ); +}; + +export default FilesTab; diff --git a/src/components/UnifiedDataBar/SourcesTab.module.css b/src/components/UnifiedDataBar/SourcesTab.module.css new file mode 100644 index 0000000..793732c --- /dev/null +++ b/src/components/UnifiedDataBar/SourcesTab.module.css @@ -0,0 +1,11 @@ +.sourcesTab { + height: 100%; + overflow-y: auto; +} + +.placeholder { + padding: 16px; + text-align: center; + color: var(--text-secondary, #6b7280); + font-size: 0.85rem; +} diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx new file mode 100644 index 0000000..403dd63 --- /dev/null +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import type { UdbContext } from './UnifiedDataBar'; +import styles from './SourcesTab.module.css'; + +interface SourcesTabProps { + context: UdbContext; + renderDataSourcePanel?: (instanceId: string) => React.ReactNode; +} + +const SourcesTab: React.FC = ({ context, renderDataSourcePanel }) => { + if (renderDataSourcePanel) { + return
{renderDataSourcePanel(context.instanceId)}
; + } + + return ( +
+
+ Datenquellen werden \u00FCber den Workspace verwaltet. +
+
+ ); +}; + +export default SourcesTab; diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.module.css b/src/components/UnifiedDataBar/UnifiedDataBar.module.css new file mode 100644 index 0000000..784d687 --- /dev/null +++ b/src/components/UnifiedDataBar/UnifiedDataBar.module.css @@ -0,0 +1,60 @@ +.udb { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.tabBar { + display: flex; + gap: 2px; + padding: 8px 8px 0; + border-bottom: 1px solid var(--border-color, #e5e7eb); + flex-shrink: 0; +} + +.tab { + flex: 1; + padding: 8px 12px; + border: none; + background: transparent; + color: var(--text-secondary, #6b7280); + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + transition: all 0.15s ease; +} + +.tab:hover { + color: var(--text-primary, #111827); + background: var(--bg-hover, rgba(0, 0, 0, 0.03)); +} + +.tabActive { + color: var(--accent, #4f46e5); + border-bottom-color: var(--accent, #4f46e5); +} + +.tabContent { + flex: 1; + overflow-y: auto; + padding: 8px; +} + +@media (prefers-color-scheme: dark) { + .tabBar { + border-bottom-color: var(--border-color-dark, #374151); + } + .tab { + color: var(--text-secondary-dark, #9ca3af); + } + .tab:hover { + color: var(--text-primary-dark, #f3f4f6); + background: rgba(255, 255, 255, 0.05); + } + .tabActive { + color: var(--accent, #818cf8); + border-bottom-color: var(--accent, #818cf8); + } +} diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx new file mode 100644 index 0000000..00ae85f --- /dev/null +++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx @@ -0,0 +1,69 @@ +import React, { useState } from 'react'; +import styles from './UnifiedDataBar.module.css'; + +export type UdbTab = 'chats' | 'files' | 'sources'; + +export interface UdbContext { + instanceId: string; + mandateId?: string; + featureInstanceId?: string; + userId?: string; +} + +interface UnifiedDataBarProps { + context: UdbContext; + activeTab?: UdbTab; + onTabChange?: (tab: UdbTab) => void; + renderChats?: (context: UdbContext) => React.ReactNode; + renderFiles?: (context: UdbContext) => React.ReactNode; + renderSources?: (context: UdbContext) => React.ReactNode; + onChatDragStart?: (chatId: string, event: React.DragEvent) => void; + className?: string; +} + +const _TAB_LABELS: Record> = { + chats: { de: 'Chats', en: 'Chats', fr: 'Chats' }, + files: { de: 'Dateien', en: 'Files', fr: 'Fichiers' }, + sources: { de: 'Quellen', en: 'Sources', fr: 'Sources' }, +}; + +const UnifiedDataBar: React.FC = ({ + context, + activeTab: controlledTab, + onTabChange, + renderChats, + renderFiles, + renderSources, + className, +}) => { + const [internalTab, setInternalTab] = useState('chats'); + const currentTab = controlledTab ?? internalTab; + + const _handleTabChange = (tab: UdbTab) => { + setInternalTab(tab); + onTabChange?.(tab); + }; + + return ( +
+
+ {(['chats', 'files', 'sources'] as UdbTab[]).map((tab) => ( + + ))} +
+
+ {currentTab === 'chats' && renderChats?.(context)} + {currentTab === 'files' && renderFiles?.(context)} + {currentTab === 'sources' && renderSources?.(context)} +
+
+ ); +}; + +export default UnifiedDataBar; diff --git a/src/components/UnifiedDataBar/index.ts b/src/components/UnifiedDataBar/index.ts new file mode 100644 index 0000000..bb63a3a --- /dev/null +++ b/src/components/UnifiedDataBar/index.ts @@ -0,0 +1,6 @@ +export { default as UnifiedDataBar } from './UnifiedDataBar'; +export type { UdbContext, UdbTab } from './UnifiedDataBar'; +export { default as ChatsTab } from './ChatsTab'; +export { default as FilesTab } from './FilesTab'; +export { default as SourcesTab } from './SourcesTab'; +export { useUdlContext } from './useUdlContext'; diff --git a/src/components/UnifiedDataBar/useUdlContext.ts b/src/components/UnifiedDataBar/useUdlContext.ts new file mode 100644 index 0000000..7bb2f89 --- /dev/null +++ b/src/components/UnifiedDataBar/useUdlContext.ts @@ -0,0 +1,23 @@ +import { useMemo } from 'react'; +import type { UdbContext } from './UnifiedDataBar'; + +/** + * Build a UDL (Unified Data Layer) context from the current feature instance. + * Features use this to query scope-based data from the UDL + * instead of instance-scoped data silos. + * + * FeatureInstance -> UI-Scope (workflow surface) + * UDL -> Data-Scope (actual data access boundary) + */ +export function useUdlContext( + instanceId: string, + mandateId?: string, + userId?: string +): UdbContext { + return useMemo(() => ({ + instanceId, + mandateId, + featureInstanceId: instanceId, + userId, + }), [instanceId, mandateId, userId]); +} diff --git a/src/hooks/useAuthentication.ts b/src/hooks/useAuthentication.ts index 9600549..a67bcac 100644 --- a/src/hooks/useAuthentication.ts +++ b/src/hooks/useAuthentication.ts @@ -279,6 +279,7 @@ export function useRegister() { interface GoogleAuthResponse { accessToken: string; tokenType: string; + isNewUser?: boolean; user: { username: string; email: string; diff --git a/src/hooks/useStore.ts b/src/hooks/useStore.ts index f13dbc3..054eaa6 100644 --- a/src/hooks/useStore.ts +++ b/src/hooks/useStore.ts @@ -11,40 +11,64 @@ import { fetchStoreFeatures, activateStoreFeature, deactivateStoreFeature, + fetchUserMandates, + fetchSubscriptionInfo, type StoreFeature, + type UserMandate, + type SubscriptionInfo, } from '../api/storeApi'; import { useFeatureStore } from '../stores/featureStore'; interface UseStoreReturn { features: StoreFeature[]; + mandates: UserMandate[]; + subscriptionInfo: SubscriptionInfo | null; loading: boolean; actionLoading: string | null; error: string | null; loadStore: () => Promise; - activate: (featureCode: string) => Promise; - deactivate: (featureCode: string) => Promise; + loadSubscriptionInfo: (mandateId?: string) => Promise; + activate: (featureCode: string, mandateId?: string) => Promise; + deactivate: (featureCode: string, mandateId: string, instanceId: string) => Promise; } export function useStore(): UseStoreReturn { const [features, setFeatures] = useState([]); + const [mandates, setMandates] = useState([]); + const [subscriptionInfo, setSubscriptionInfo] = useState(null); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(null); const [error, setError] = useState(null); const featureStore = useFeatureStore(); + const loadSubscriptionInfo = useCallback(async (mandateId?: string) => { + try { + const info = await fetchSubscriptionInfo(mandateId); + setSubscriptionInfo(info); + } catch { + // non-critical + } + }, []); + const loadStore = useCallback(async () => { setLoading(true); setError(null); try { - const data = await fetchStoreFeatures(); + const [data, userMandates] = await Promise.all([ + fetchStoreFeatures(), + fetchUserMandates(), + ]); setFeatures(data); + setMandates(userMandates); + const firstMandateId = userMandates.length > 0 ? userMandates[0].id : undefined; + await loadSubscriptionInfo(firstMandateId); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Failed to load store'; setError(msg); } finally { setLoading(false); } - }, []); + }, [loadSubscriptionInfo]); useEffect(() => { loadStore(); @@ -56,11 +80,11 @@ export function useStore(): UseStoreReturn { await loadStore(); }, [featureStore, loadStore]); - const activate = useCallback(async (featureCode: string) => { + const activate = useCallback(async (featureCode: string, mandateId?: string) => { setActionLoading(featureCode); setError(null); try { - await activateStoreFeature(featureCode); + await activateStoreFeature(featureCode, mandateId); await _refreshAfterAction(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Activation failed'; @@ -70,11 +94,11 @@ export function useStore(): UseStoreReturn { } }, [_refreshAfterAction]); - const deactivate = useCallback(async (featureCode: string) => { + const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => { setActionLoading(featureCode); setError(null); try { - await deactivateStoreFeature(featureCode); + await deactivateStoreFeature(featureCode, mandateId, instanceId); await _refreshAfterAction(); } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Deactivation failed'; @@ -84,7 +108,7 @@ export function useStore(): UseStoreReturn { } }, [_refreshAfterAction]); - return { features, loading, actionLoading, error, loadStore, activate, deactivate }; + return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate }; } export default useStore; diff --git a/src/pages/Login.module.css b/src/pages/Login.module.css index a66ca81..16905c5 100644 --- a/src/pages/Login.module.css +++ b/src/pages/Login.module.css @@ -242,6 +242,50 @@ text-decoration: underline; } +.ctaSection { + display: flex; + gap: 0.75rem; + width: 100%; +} + +.ctaPrimary { + flex: 1; + height: 46px; + padding: 10px 16px; + border-radius: 25px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + border: none; + background-color: var(--color-secondary); + color: var(--color-text); + transition: all 0.2s ease; + font-family: var(--font-family); +} + +.ctaPrimary:hover { + background-color: var(--color-secondary-hover); +} + +.ctaSecondary { + flex: 1; + height: 46px; + padding: 10px 16px; + border-radius: 25px; + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + border: 1px solid var(--color-secondary); + background-color: transparent; + color: var(--color-secondary); + transition: all 0.2s ease; + font-family: var(--font-family); +} + +.ctaSecondary:hover { + background-color: color-mix(in srgb, var(--color-secondary) 10%, transparent); +} + button:disabled { opacity: 0.7; cursor: not-allowed; diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index f5673ee..6fa20a7 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -5,6 +5,7 @@ import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText } from 'react-icons/fa'; import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication'; import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; import { PENDING_INVITATION_KEY } from './InvitePage'; +import OnboardingWizard from '../components/OnboardingWizard'; import styles from './Login.module.css'; @@ -21,6 +22,7 @@ function Login() { const { login, error: loginError, isLoading: isLoginLoading } = useAuth(); const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth(); const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth(); + const [showOnboardingWizard, setShowOnboardingWizard] = useState(false); // Check for pending invitation const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); @@ -84,6 +86,10 @@ function Login() { console.log("Attempting Google login..."); const response = await loginWithGoogle(); console.log("Google login successful:", response); + if (response?.isNewUser) { + setShowOnboardingWizard(true); + return; + } handleSuccessfulLogin(); } catch (error) { console.error("Google login failed:", error); @@ -104,6 +110,21 @@ function Login() { } }; + if (showOnboardingWizard) { + return ( + { + setShowOnboardingWizard(false); + handleSuccessfulLogin(); + }} + onDismiss={() => { + setShowOnboardingWizard(false); + handleSuccessfulLogin(); + }} + /> + ); + } + return (
@@ -213,12 +234,22 @@ function Login() {
- Du hast noch keinen Konto? + Du hast noch kein Konto? +
+
+
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 8c71330..95051cd 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { FaEnvelopeOpenText } from 'react-icons/fa'; import styles from './Register.module.css'; @@ -27,6 +27,10 @@ function Register() { email: invitationEmail, fullName: '' }); + const [searchParams] = useSearchParams(); + const registrationType = searchParams.get('type') === 'company' ? 'company' : 'personal'; + const [companyName, setCompanyName] = useState(''); + const [companyNameFocused, setCompanyNameFocused] = useState(false); const [validationError, setValidationError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [usernameFocused, setUsernameFocused] = useState(false); @@ -40,11 +44,13 @@ function Register() { // Set page title and generate CSRF token useEffect(() => { - document.title = "PowerOn AI Platform - Registrieren"; + document.title = registrationType === 'company' + ? "PowerOn AI Platform - Unternehmenskonto erstellen" + : "PowerOn AI Platform - Kostenlos testen"; // Generate CSRF token for new security implementation generateAndStoreCSRFToken(); - }, []); + }, [registrationType]); const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -70,6 +76,11 @@ function Register() { return false; } + if (registrationType === 'company' && !companyName.trim()) { + setValidationError('Bitte geben Sie einen Firmennamen ein.'); + return false; + } + return true; }; @@ -97,7 +108,7 @@ function Register() { } // Username is available, proceed with registration (no password - magic link flow) - await register(formData); + await register({ ...formData, registrationType, companyName: registrationType === 'company' ? companyName : undefined }); // Build success message let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.'; @@ -192,6 +203,22 @@ function Register() {
+ {registrationType === 'company' && ( +
+ setCompanyName(e.target.value)} + onFocus={() => setCompanyNameFocused(true)} + onBlur={() => setCompanyNameFocused(false)} + className={`${styles.input} ${companyNameFocused || companyName ? styles.focused : ''}`} + /> + +
+ )} +
- {isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : "Registrieren"} + {isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : registrationType === 'company' ? 'Unternehmenskonto erstellen' : 'Kostenlos testen'} )} diff --git a/src/pages/Store.module.css b/src/pages/Store.module.css index 6383188..a6e1897 100644 --- a/src/pages/Store.module.css +++ b/src/pages/Store.module.css @@ -29,6 +29,52 @@ font-size: 0.9375rem; } +/* Subscription Banner */ +.subscriptionBanner { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.25rem; + padding: 0.75rem 1rem; + margin-bottom: 1.5rem; + border-radius: 8px; + font-size: 0.8125rem; + background: var(--info-bg, #eff6ff); + border: 1px solid var(--info-border, #bfdbfe); + color: var(--info-color, #1e40af); +} + +.bannerSeparator::before { + content: '|'; + margin-right: 0.25rem; + opacity: 0.4; +} + +/* Mandate Select */ +.mandateSelect { + width: 100%; + padding: 0.5rem 0.75rem; + margin-bottom: 0.5rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + font-size: 0.8125rem; + background: var(--surface-color, #ffffff); + color: var(--text-primary, #1a1a1a); + appearance: auto; +} + +.mandateSelect:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.mandateHint { + margin: 0 0 0.5rem; + font-size: 0.75rem; + color: var(--text-secondary, #666); + font-style: italic; +} + /* Grid */ .grid { display: grid; @@ -120,6 +166,49 @@ background: currentColor; } +/* Instance List */ +.instanceList { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.instanceRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.instanceInfo { + min-width: 0; + overflow: hidden; +} + +.deactivateButtonSmall { + flex-shrink: 0; + padding: 0.25rem 0.625rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 6px; + font-size: 0.75rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background: transparent; + color: var(--text-secondary, #666); +} + +.deactivateButtonSmall:hover:not(:disabled) { + border-color: var(--error-color, #dc2626); + color: var(--error-color, #dc2626); + background: var(--error-bg, #fef2f2); +} + +.deactivateButtonSmall:disabled { + opacity: 0.6; + cursor: not-allowed; +} + /* Actions */ .cardActions { padding-top: 0.5rem; @@ -243,17 +332,35 @@ border-top-color: var(--border-dark, #333); } -:global(.dark-theme) .deactivateButton { +:global(.dark-theme) .deactivateButton, +:global(.dark-theme) .deactivateButtonSmall { border-color: var(--border-dark, #444); color: var(--text-secondary-dark, #aaa); } -:global(.dark-theme) .deactivateButton:hover:not(:disabled) { +:global(.dark-theme) .deactivateButton:hover:not(:disabled), +:global(.dark-theme) .deactivateButtonSmall:hover:not(:disabled) { border-color: var(--error-color-dark, #f87171); color: var(--error-color-dark, #f87171); background: rgba(248, 113, 113, 0.1); } +:global(.dark-theme) .subscriptionBanner { + background: rgba(37, 99, 235, 0.1); + border-color: rgba(37, 99, 235, 0.25); + color: var(--primary-light, #93bbfc); +} + +:global(.dark-theme) .mandateSelect { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #444); + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .mandateHint { + color: var(--text-secondary-dark, #aaa); +} + :global(.dark-theme) .error { background: var(--error-bg-dark, #450a0a); border-color: var(--error-border-dark, #991b1b); diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx index fd0e467..c4f901b 100644 --- a/src/pages/Store.tsx +++ b/src/pages/Store.tsx @@ -6,11 +6,11 @@ * and users get their own FeatureAccess + user-role upon activation. */ -import React from 'react'; +import React, { useState } from 'react'; import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa'; import { useLanguage } from '../providers/language/LanguageContext'; import { useStore } from '../hooks/useStore'; -import type { StoreFeature } from '../api/storeApi'; +import type { StoreFeature, UserMandate } from '../api/storeApi'; import styles from './Store.module.css'; const FEATURE_ICONS: Record = { @@ -62,23 +62,39 @@ function _getDescription(featureCode: string, lang: string): string { interface FeatureCardProps { feature: StoreFeature; language: string; + mandates: UserMandate[]; actionLoading: string | null; - onActivate: (code: string) => void; - onDeactivate: (code: string) => void; + onActivate: (code: string, mandateId?: string) => void; + onDeactivate: (code: string, mandateId: string, instanceId: string) => void; } const FeatureCard: React.FC = ({ feature, language, + mandates, actionLoading, onActivate, onDeactivate, }) => { + const [selectedMandateId, setSelectedMandateId] = useState(''); const isProcessing = actionLoading === feature.featureCode; const icon = FEATURE_ICONS[feature.featureCode]; + const activeInstances = feature.instances.filter(inst => inst.isActive); + const hasActive = activeInstances.length > 0; + const needsMandateSelection = mandates.length > 1; + + const _handleActivate = () => { + if (needsMandateSelection) { + onActivate(feature.featureCode, selectedMandateId || undefined); + } else if (mandates.length === 1) { + onActivate(feature.featureCode, mandates[0].id); + } else { + onActivate(feature.featureCode); + } + }; return ( -
+
{icon && {icon}}

@@ -92,36 +108,76 @@ const FeatureCard: React.FC = ({

-
- - - {feature.isActive - ? (language === 'de' ? 'Aktiv' : language === 'fr' ? 'Actif' : 'Active') - : (language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available')} - -
+ {activeInstances.length > 0 && ( +
+ {activeInstances.map((inst) => ( +
+
+ + + {inst.mandateName || inst.label} + +
+ +
+ ))} +
+ )} + + {activeInstances.length === 0 && ( +
+ + + {language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available'} + +
+ )}
- {feature.isActive ? ( - - ) : ( - + {feature.canActivate && ( + <> + {mandates.length === 0 && ( +

+ {language === 'de' + ? 'Ein persoenliches Konto wird automatisch erstellt.' + : language === 'fr' + ? 'Un compte personnel sera cree automatiquement.' + : 'A personal account will be created automatically.'} +

+ )} + {needsMandateSelection && ( + + )} + + )}
@@ -130,7 +186,7 @@ const FeatureCard: React.FC = ({ const StorePage: React.FC = () => { const { currentLanguage } = useLanguage(); - const { features, loading, actionLoading, error, activate, deactivate } = useStore(); + const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore(); return (
@@ -145,6 +201,27 @@ const StorePage: React.FC = () => {

+ {subscriptionInfo && subscriptionInfo.plan && ( +
+ Plan: {subscriptionInfo.plan} + {subscriptionInfo.maxFeatureInstances != null && ( + + {currentLanguage === 'de' ? 'Instanzen' : 'Instances'}: {subscriptionInfo.currentFeatureInstances}/{subscriptionInfo.maxFeatureInstances} + + )} + {subscriptionInfo.maxDataVolumeMB != null && ( + + {currentLanguage === 'de' ? 'Speicher' : 'Storage'}: {subscriptionInfo.maxDataVolumeMB} MB + + )} + {subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && ( + + {currentLanguage === 'de' ? 'Trial endet' : 'Trial ends'}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()} + + )} +
+ )} + {error &&
{error}
} {loading ? ( @@ -164,6 +241,7 @@ const StorePage: React.FC = () => { key={feature.featureCode} feature={feature} language={currentLanguage} + mandates={mandates} actionLoading={actionLoading} onActivate={activate} onDeactivate={deactivate} diff --git a/src/pages/views/commcoach/CommcoachDossierView.module.css b/src/pages/views/commcoach/CommcoachDossierView.module.css index d078335..006680c 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.module.css +++ b/src/pages/views/commcoach/CommcoachDossierView.module.css @@ -1,7 +1,56 @@ +/* Outer flex layout: UDB sidebar + main dossier */ +.dossierLayout { + display: flex; + height: calc(100vh - 140px); + overflow: hidden; +} + +.udbSidebar { + width: 280px; + min-width: 280px; + border-right: 1px solid var(--border-color, #e0e0e0); + display: flex; + flex-direction: column; + background: var(--bg-card, #fff); + overflow: hidden; + position: relative; + transition: width 0.2s, min-width 0.2s; +} + +.udbSidebarCollapsed { + width: 36px; + min-width: 36px; +} + +.udbToggle { + position: absolute; + top: 8px; + right: 4px; + z-index: 2; + width: 24px; + height: 24px; + padding: 0; + border: 1px solid var(--border-color, #ddd); + border-radius: 4px; + background: var(--bg-card, #fff); + cursor: pointer; + font-size: 0.65rem; + color: var(--text-secondary, #888); + display: flex; + align-items: center; + justify-content: center; +} + +.udbToggle:hover { + background: var(--bg-hover, #f5f5f5); + color: var(--primary-color, #F25843); +} + .dossier { display: flex; flex-direction: column; - height: calc(100vh - 140px); + flex: 1; + min-width: 0; overflow: hidden; } diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx index 50f0346..dd70058 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.tsx +++ b/src/pages/views/commcoach/CommcoachDossierView.tsx @@ -1,48 +1,53 @@ /** * CommCoach Dossier View (Main View) * - * Unified view per context: Coaching session, Tasks, Sessions history, Scores, Documents. + * Unified view per context: Coaching session, Tasks, Sessions history, Scores. * Voice first, always with text fallback. + * Files & Sources are provided via the shared UnifiedDataBar sidebar. */ import React, { useState, useRef, useCallback, useEffect } from 'react'; import { useCommcoach } from '../../../hooks/useCommcoach'; import { type TtsEvent } from '../../../hooks/useTtsPlayback'; import { useApiRequest } from '../../../hooks/useApi'; -import { useInstanceId } from '../../../hooks/useCurrentInstance'; -import api from '../../../api'; +import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance'; import { getDossierExportUrl, getSessionExportUrl, - getDocumentsApi, uploadDocumentApi, deleteDocumentApi, getScoreHistoryApi, getPersonasApi, - type CoachingDocument, type CoachingPersona, + type CoachingPersona, } from '../../../api/commcoachApi'; import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import { UnifiedDataBar, FilesTab, SourcesTab } from '../../../components/UnifiedDataBar'; +import type { UdbContext } from '../../../components/UnifiedDataBar'; import styles from './CommcoachDossierView.module.css'; import { useVoiceController } from './useVoiceController'; -type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores' | 'documents'; +type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores'; export const CommcoachDossierView: React.FC = () => { const coach = useCommcoach(); const { request } = useApiRequest(); const instanceId = useInstanceId(); + const mandateId = useMandateId(); const [activeTab, setActiveTab] = useState('coaching'); const [showNewContext, setShowNewContext] = useState(false); const [newTitle, setNewTitle] = useState(''); const [newDescription, setNewDescription] = useState(''); const [newCategory, setNewCategory] = useState('custom'); + const [udbCollapsed, setUdbCollapsed] = useState(false); const [newTaskTitle, setNewTaskTitle] = useState(''); - const [documents, setDocuments] = useState([]); - const [uploading, setUploading] = useState(false); const [scoreHistory, setScoreHistory] = useState>>({}); const [personas, setPersonas] = useState([]); const [selectedPersonaId, setSelectedPersonaId] = useState(undefined); + const _udbContext: UdbContext | null = instanceId + ? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId } + : null; + const inputRef = useRef(null); const sendMessageRef = useRef(coach.sendMessage); sendMessageRef.current = coach.sendMessage; @@ -82,27 +87,14 @@ export const CommcoachDossierView: React.FC = () => { } }, [coach.contexts, coach.selectedContextId, coach.selectContext]); - // Load documents, scores, personas when context changes + // Load scores, personas when context changes useEffect(() => { if (!instanceId || !coach.selectedContextId) return; - getDocumentsApi(request, instanceId, coach.selectedContextId) - .then(d => setDocuments(d)) - .catch(() => {}); getScoreHistoryApi(request, instanceId, coach.selectedContextId) .then(h => setScoreHistory(h)) .catch(() => {}); }, [instanceId, request, coach.selectedContextId]); - useEffect(() => { - coach.onDocumentCreatedRef.current = (doc) => { - setDocuments(prev => { - if (prev.some(d => d.id === doc.id)) return prev; - return [doc, ...prev]; - }); - }; - return () => { coach.onDocumentCreatedRef.current = null; }; - }, [coach.onDocumentCreatedRef]); - useEffect(() => { if (!instanceId) return; getPersonasApi(request, instanceId) @@ -144,46 +136,6 @@ export const CommcoachDossierView: React.FC = () => { coach.selectContext(contextId, { skipSessionResume: true }); }, [coach]); - const handleUpload = useCallback(async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file || !instanceId || !coach.selectedContextId) return; - setUploading(true); - try { - const doc = await uploadDocumentApi(instanceId, coach.selectedContextId, file); - setDocuments(prev => [doc, ...prev]); - } catch { /* upload failed */ } finally { - setUploading(false); - e.target.value = ''; - } - }, [instanceId, coach.selectedContextId]); - - const handleDeleteDocument = useCallback(async (docId: string) => { - if (!instanceId) return; - try { - await deleteDocumentApi(request, instanceId, docId); - setDocuments(prev => prev.filter(d => d.id !== docId)); - } catch { /* delete failed */ } - }, [instanceId, request]); - - const handleDownloadDocument = useCallback(async (doc: CoachingDocument) => { - if (!doc.fileRef) return; - try { - const response = await api.get(`/api/files/${doc.fileRef}/download`, { - responseType: 'blob', - }); - const url = window.URL.createObjectURL(response.data); - const a = document.createElement('a'); - a.href = url; - a.download = doc.fileName; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - window.URL.revokeObjectURL(url); - } catch (err) { - console.error('Download failed:', err); - } - }, []); - const handleAddTask = useCallback(async () => { if (!newTaskTitle.trim()) return; await coach.addTask(newTaskTitle); @@ -195,7 +147,31 @@ export const CommcoachDossierView: React.FC = () => { } return ( -
+
+ {/* UDB Sidebar */} + {_udbContext && ( +
+ + {!udbCollapsed && ( + null} + renderFiles={(ctx) => } + renderSources={(ctx) => } + /> + )} +
+ )} + + {/* Main Content */} +
{/* Context Selector */}
{coach.contexts.map(ctx => ( @@ -286,13 +262,13 @@ export const CommcoachDossierView: React.FC = () => { {/* Tab Navigation */}
- {(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => ( + {(['coaching', 'tasks', 'sessions', 'scores'] as TabKey[]).map(tab => ( ))}
@@ -546,40 +522,6 @@ export const CommcoachDossierView: React.FC = () => {
)} - {/* ============================================================ */} - {/* DOCUMENTS TAB */} - {/* ============================================================ */} - {activeTab === 'documents' && ( -
-
- -
- {documents.length === 0 ? ( -
Keine Dokumente. Lade Dateien hoch oder bitte den Coach, eines zu erstellen.
- ) : ( -
- {documents.map(doc => ( -
-
-
{doc.fileName}
-
- {_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''} -
- {doc.summary &&
{doc.summary}
} -
-
- - -
-
- ))} -
- )} -
- )} )} {/* #region agent log */}
@@ -595,6 +537,7 @@ export const CommcoachDossierView: React.FC = () => {
{/* #endregion */}
+
); }; @@ -607,13 +550,12 @@ function _categoryIcon(category: string): string { return icons[category] || '*'; } -function _tabLabel(tab: TabKey, coach: any, documents: CoachingDocument[]): string { +function _tabLabel(tab: TabKey, coach: any): string { switch (tab) { case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching'; case 'tasks': return `Aufgaben (${coach.tasks.length})`; case 'sessions': return `Sessions (${coach.sessions.length})`; case 'scores': return `Bewertungen (${coach.scores.length})`; - case 'documents': return `Dokumente (${documents.length})`; } } @@ -634,12 +576,6 @@ function _groupScoresByDimension(scores: any[]): ScoreGroup[] { return Object.values(groups); } -function _formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - function _dimensionLabel(dim: string): string { const labels: Record = { empathy: 'Einfühlungsvermögen', clarity: 'Klarheit', diff --git a/src/pages/views/commcoach/CommcoachSettingsView.tsx b/src/pages/views/commcoach/CommcoachSettingsView.tsx index b7af4cd..47613db 100644 --- a/src/pages/views/commcoach/CommcoachSettingsView.tsx +++ b/src/pages/views/commcoach/CommcoachSettingsView.tsx @@ -7,6 +7,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useApiRequest } from '../../../hooks/useApi'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; +import api from '../../../api'; import { getProfileApi, updateProfileApi, getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi, @@ -14,6 +15,18 @@ import { } from '../../../api/commcoachApi'; import styles from './CommcoachSettingsView.module.css'; +async function _syncSharedVoicePreferences(lang: string, voice?: string): Promise { + try { + await api.put('/api/local/voice-preferences', { + sttLanguage: lang, + ttsLanguage: lang, + ttsVoice: voice ?? null, + }); + } catch { + // Silent fallback — shared prefs sync is best-effort + } +} + export const CommcoachSettingsView: React.FC = () => { const { request } = useApiRequest(); const instanceId = useInstanceId(); @@ -88,6 +101,9 @@ export const CommcoachSettingsView: React.FC = () => { emailSummaryEnabled: emailEnabled, }); setProfile(updated); + + _syncSharedVoicePreferences(language, voiceId || undefined); + setSuccess('Einstellungen gespeichert'); setTimeout(() => setSuccess(null), 3000); } catch (err: any) { diff --git a/src/pages/views/workspace/ChatStream.tsx b/src/pages/views/workspace/ChatStream.tsx index 0b5c330..6596820 100644 --- a/src/pages/views/workspace/ChatStream.tsx +++ b/src/pages/views/workspace/ChatStream.tsx @@ -147,6 +147,32 @@ export const ChatStream: React.FC = ({ charCount={(msg as any)._audioCharCount} /> )} + {msg.role === 'assistant' && msg.documents && msg.documents.length > 0 && ( +
+ + Gesendete Daten ({msg.documents.length} {msg.documents.length === 1 ? 'Dokument' : 'Dokumente'}) + +
+ {msg.documents.map((doc, idx) => ( +
+ + {doc.documentName || doc.fileName || `Dokument ${idx + 1}`} + + {doc.validationMetadata?.neutralized && ( + + neutralisiert + + )} + {doc.validationMetadata?.skipped && ( + + übersprungen + + )} +
+ ))} +
+
+ )}
)}
diff --git a/src/pages/views/workspace/NeutralizationPanel.tsx b/src/pages/views/workspace/NeutralizationPanel.tsx new file mode 100644 index 0000000..a13d52d --- /dev/null +++ b/src/pages/views/workspace/NeutralizationPanel.tsx @@ -0,0 +1,191 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import api from '../../../api'; + +interface NeutralizationMapping { + id: string; + originalText: string; + placeholder: string; + patternType: string; + fileId?: string; + fileName?: string; + createdAt?: string; +} + +interface NeutralizationSource { + fileId: string; + fileName: string; + neutralizationStatus: string; + mappingCount: number; +} + +interface NeutralizationPanelProps { + instanceId: string; +} + +const NeutralizationPanel: React.FC = ({ instanceId }) => { + const [sources, setSources] = useState([]); + const [selectedSource, setSelectedSource] = useState(null); + const [mappings, setMappings] = useState([]); + const [loading, setLoading] = useState(true); + + const _loadSources = useCallback(async () => { + setLoading(true); + try { + const response = await api.get(`/api/workspace/${instanceId}/files`); + const files = response.data?.data || response.data || []; + const neutralized = files + .filter((f: any) => f.neutralize) + .map((f: any) => ({ + fileId: f.id, + fileName: f.fileName || f.name || 'unknown', + neutralizationStatus: f.neutralizationStatus || f.status || 'unknown', + mappingCount: 0, + })); + setSources(neutralized); + } catch (err) { + console.error('Failed to load neutralization sources:', err); + } finally { + setLoading(false); + } + }, [instanceId]); + + const _loadMappings = useCallback(async (fileId: string) => { + try { + const response = await api.get(`/api/neutralization/${instanceId}/attributes`, { params: { fileId } }); + const data = response.data?.data || response.data || []; + setMappings(data.map((m: any) => ({ + id: m.id, + originalText: m.originalText || '', + placeholder: m.placeholder || m.id, + patternType: m.patternType || 'unknown', + fileId: m.fileId, + fileName: m.fileName, + createdAt: m.createdAt || m._createdAt, + }))); + } catch (err) { + console.error('Failed to load mappings:', err); + setMappings([]); + } + }, [instanceId]); + + useEffect(() => { _loadSources(); }, [_loadSources]); + + useEffect(() => { + if (selectedSource) _loadMappings(selectedSource); + }, [selectedSource, _loadMappings]); + + const _handleDeleteMapping = async (mappingId: string) => { + try { + await api.delete(`/api/neutralization/${instanceId}/attributes/single/${mappingId}`); + setMappings(prev => prev.filter(m => m.id !== mappingId)); + } catch (err) { + console.error('Failed to delete mapping:', err); + } + }; + + const _handleRetrigger = async (fileId: string) => { + try { + await api.post(`/api/neutralization/${instanceId}/retrigger`, { fileId }); + _loadSources(); + } catch (err) { + console.error('Failed to retrigger neutralization:', err); + } + }; + + const _statusBadge = (status: string) => { + const colors: Record = { + completed: { bg: '#dcfce7', text: '#166534' }, + pending: { bg: '#fef3c7', text: '#92400e' }, + failed: { bg: '#fef2f2', text: '#991b1b' }, + not_required: { bg: '#f3f4f6', text: '#6b7280' }, + }; + const c = colors[status] || colors.not_required; + return ( + + {status} + + ); + }; + + if (loading) return
Lade Neutralisierungsdaten...
; + + return ( +
+

Neutralisierung

+

+ Übersicht aller Datenquellen mit Neutralisierung und deren Platzhalter-Mappings. +

+ + {sources.length === 0 ? ( +
+ Keine Datenquellen mit aktiver Neutralisierung. +
+ ) : ( +
+ {sources.map((src) => ( +
setSelectedSource(src.fileId === selectedSource ? null : src.fileId)} + > +
+
{src.fileName}
+
+ {_statusBadge(src.neutralizationStatus)} +
+
+
+ + + {selectedSource === src.fileId ? '\u25BC' : '\u25B6'} + +
+
+ ))} +
+ )} + + {selectedSource && mappings.length > 0 && ( +
+
+ Platzhalter-Mappings ({mappings.length}) +
+
+ {mappings.map((m) => ( +
+ {m.placeholder} + {'\u2192'} + {m.originalText} + {m.patternType} + +
+ ))} +
+
+ )} + + {selectedSource && mappings.length === 0 && ( +
+ Keine Mappings für diese Datenquelle. +
+ )} +
+ ); +}; + +export default NeutralizationPanel; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index 0a091c4..e349e24 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -263,7 +263,10 @@ export const WorkspaceInput: React.FC = ({ }, [onPasteAsFile]); const _handlePromptDragOver = useCallback((e: React.DragEvent) => { - if (e.dataTransfer.types.includes('application/tree-items')) { + if ( + e.dataTransfer.types.includes('application/tree-items') || + e.dataTransfer.types.includes('application/chat-id') + ) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; setTreeDropOver(true); @@ -273,11 +276,22 @@ export const WorkspaceInput: React.FC = ({ const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []); const _handlePromptDrop = useCallback((e: React.DragEvent) => { + setTreeDropOver(false); + + const chatId = e.dataTransfer.getData('application/chat-id'); + if (chatId) { + e.preventDefault(); + e.stopPropagation(); + const chatLabel = e.dataTransfer.getData('text/plain'); + const ref = chatLabel ? `[Chat: ${chatLabel}]` : `[Chat: ${chatId.slice(0, 8)}]`; + setPrompt(prev => (prev ? `${prev} ${ref}` : ref)); + return; + } + const treeItemsJson = e.dataTransfer.getData('application/tree-items'); if (treeItemsJson && onTreeItemsDrop) { e.preventDefault(); e.stopPropagation(); - setTreeDropOver(false); const items: TreeItemDrop[] = JSON.parse(treeItemsJson); onTreeItemsDrop(items); } diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx index 0466ee8..874e807 100644 --- a/src/pages/views/workspace/WorkspacePage.tsx +++ b/src/pages/views/workspace/WorkspacePage.tsx @@ -19,6 +19,9 @@ import { FileBrowser } from './FileBrowser'; import { DataSourcePanel } from './DataSourcePanel'; import { FilePreview } from './FilePreview'; import { ToolActivityLog } from './ToolActivityLog'; +import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; +import type { UdbContext } from '../../../components/UnifiedDataBar'; +import OnboardingAssistant from '../../../components/OnboardingAssistant'; function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) { const [width, setWidth] = useState(initialWidth); @@ -52,7 +55,6 @@ function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) return { width, onMouseDown: _onMouseDown }; } -type LeftTab = 'conversations' | 'files' | 'datasources'; type RightTab = 'activity' | 'preview'; interface PendingFile { @@ -78,7 +80,6 @@ export const WorkspacePage: React.FC = ({ persistentInstance const [rightCollapsed, setRightCollapsed] = useState(false); const _leftResize = _useResizable(280, 200, 450); const _rightResize = _useResizable(320, 200, 500); - const [leftTab, setLeftTab] = useState('conversations'); const [rightTab, setRightTab] = useState('activity'); const [selectedFileId, setSelectedFileId] = useState(null); const [pendingFiles, setPendingFiles] = useState([]); @@ -210,43 +211,42 @@ export const WorkspacePage: React.FC = ({ persistentInstance textTransform: 'uppercase' as const, }); - const _leftPanelBody = ( - <> -
- - - -
+ const _udbContext: UdbContext = { + instanceId: instanceId, + mandateId: mandateId, + featureInstanceId: instanceId, + }; -
- {leftTab === 'conversations' && ( - - )} - {leftTab === 'files' && ( - - )} - {leftTab === 'datasources' && ( - - )} -
- + const _leftPanelBody = ( + ( + + )} + renderFiles={(ctx) => ( + + )} + renderSources={(ctx) => ( + + )} + /> ); const _rightPanelBody = ( @@ -386,6 +386,11 @@ export const WorkspacePage: React.FC = ({ persistentInstance Dateien hier ablegen
)} + { @@ -69,6 +71,9 @@ export const WorkspaceSettingsPage: React.FC = () => { {activeTab === 'voice' && ( )} + {activeTab === 'neutralization' && ( + + )}
); From 9a7e3f42d2a03e878fd631b01fc98239f64baee4 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Tue, 24 Mar 2026 16:39:31 +0100 Subject: [PATCH 02/11] unified data completed implementation --- src/api/commcoachApi.ts | 71 -- src/components/OnboardingAssistant.tsx | 2 +- src/pages/Settings.tsx | 608 ++++++++++-------- .../views/commcoach/CommcoachSettingsView.tsx | 163 +---- .../WorkspaceGeneralSettings.module.css | 16 + .../workspace/WorkspaceGeneralSettings.tsx | 2 +- src/pages/views/workspace/WorkspaceInput.tsx | 18 +- .../workspace/WorkspaceSettings.module.css | 173 ----- .../views/workspace/WorkspaceSettings.tsx | 280 -------- .../views/workspace/WorkspaceSettingsPage.tsx | 13 +- 10 files changed, 393 insertions(+), 953 deletions(-) create mode 100644 src/pages/views/workspace/WorkspaceGeneralSettings.module.css delete mode 100644 src/pages/views/workspace/WorkspaceSettings.module.css delete mode 100644 src/pages/views/workspace/WorkspaceSettings.tsx diff --git a/src/api/commcoachApi.ts b/src/api/commcoachApi.ts index ef9b0be..47f5665 100644 --- a/src/api/commcoachApi.ts +++ b/src/api/commcoachApi.ts @@ -50,18 +50,6 @@ export interface CoachingPersona { isActive: boolean; } -export interface CoachingDocument { - id: string; - contextId: string; - fileName: string; - mimeType: string; - fileSize: number; - extractedText?: string; - summary?: string; - fileRef?: string; - createdAt?: string; -} - export interface CoachingBadge { id: string; userId: string; @@ -110,8 +98,6 @@ export interface CoachingScore { export interface CoachingUserProfile { id: string; userId: string; - preferredLanguage: string; - preferredVoice?: string; dailyReminderTime?: string; dailyReminderEnabled: boolean; emailSummaryEnabled: boolean; @@ -494,27 +480,6 @@ export async function updateProfileApi(request: ApiRequestFunction, instanceId: return data.profile; } -// ============================================================================ -// Voice API -// ============================================================================ - -export async function getVoiceLanguagesApi(request: ApiRequestFunction, instanceId: string): Promise { - const data = await request({ url: `/api/commcoach/${instanceId}/voice/languages`, method: 'get' }); - return data.languages || []; -} - -export async function getVoiceVoicesApi(request: ApiRequestFunction, instanceId: string, language: string = 'de-DE'): Promise { - const data = await request({ url: `/api/commcoach/${instanceId}/voice/voices`, method: 'get', params: { language } }); - return data.voices || []; -} - -export async function testVoiceApi(request: ApiRequestFunction, instanceId: string, body: { - text?: string; language?: string; voiceId?: string; -}): Promise<{ success: boolean; audio?: string; format?: string; text?: string }> { - const data = await request({ url: `/api/commcoach/${instanceId}/voice/tts`, method: 'post', data: body }); - return data; -} - // ============================================================================ // Persona API (Iteration 2) // ============================================================================ @@ -535,42 +500,6 @@ export async function deletePersonaApi(request: ApiRequestFunction, instanceId: await request({ url: `/api/commcoach/${instanceId}/personas/${personaId}`, method: 'delete' }); } -// ============================================================================ -// Document API (Iteration 2) -// ============================================================================ - -export async function getDocumentsApi(request: ApiRequestFunction, instanceId: string, contextId: string): Promise { - const data = await request({ url: `/api/commcoach/${instanceId}/contexts/${contextId}/documents`, method: 'get' }); - return data.documents || []; -} - -export async function uploadDocumentApi(instanceId: string, contextId: string, file: File): Promise { - const baseURL = api.defaults.baseURL || ''; - const url = `${baseURL}/api/commcoach/${instanceId}/contexts/${contextId}/documents`; - const formData = new FormData(); - formData.append('file', file); - - const headers: Record = {}; - const authToken = localStorage.getItem('authToken'); - if (authToken) headers['Authorization'] = `Bearer ${authToken}`; - const pathMatch = window.location.pathname.match(/^\/mandates\/([^/]+)\/([^/]+)\/([^/]+)/); - if (pathMatch) { - headers['X-Mandate-Id'] = pathMatch[1]; - headers['X-Instance-Id'] = pathMatch[3]; - } - if (!getCSRFToken()) generateAndStoreCSRFToken(); - addCSRFTokenToHeaders(headers); - - const response = await fetch(url, { method: 'POST', headers, body: formData, credentials: 'include' }); - if (!response.ok) throw new Error(`Upload failed: ${response.status}`); - const data = await response.json(); - return data.document; -} - -export async function deleteDocumentApi(request: ApiRequestFunction, instanceId: string, documentId: string): Promise { - await request({ url: `/api/commcoach/${instanceId}/documents/${documentId}`, method: 'delete' }); -} - // ============================================================================ // Badge API (Iteration 2) // ============================================================================ diff --git a/src/components/OnboardingAssistant.tsx b/src/components/OnboardingAssistant.tsx index 97bd5b0..0ebf492 100644 --- a/src/components/OnboardingAssistant.tsx +++ b/src/components/OnboardingAssistant.tsx @@ -23,7 +23,7 @@ const _DISMISS_COOLDOWN_MS = 24 * 60 * 60 * 1000; const OnboardingAssistant: React.FC = ({ instanceId, mandateId, - featureCode, + featureCode: _featureCode, onDismiss, }) => { const navigate = useNavigate(); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 12bdec7..8a8ae74 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -1,18 +1,31 @@ /** - * Settings Page - * - * Benutzer-Einstellungen (System-Level, ohne Instanz-Kontext). + * Settings Page — User-level settings with tabs. + * Route: /settings */ -import React, { useState, useCallback } from 'react'; +import React, { useState, useCallback, useEffect } from 'react'; import { Link } from 'react-router-dom'; import { useLanguage } from '../providers/language/LanguageContext'; import { useCurrentUser, useUser } from '../hooks/useUsers'; 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 styles from './Settings.module.css'; +// ============================================================================= +// TYPES +// ============================================================================= + +type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy'; + +const _TABS: { key: SettingsTab; label: string }[] = [ + { key: 'profile', label: 'Profil' }, + { key: 'appearance', label: 'Darstellung' }, + { key: 'voice', label: 'Stimme & Sprache' }, + { key: 'privacy', label: 'Datenschutz' }, +]; + // ============================================================================= // PROFILE EDIT MODAL // ============================================================================= @@ -27,39 +40,13 @@ interface ProfileEditModalProps { const ProfileEditModal: React.FC = ({ isOpen, onClose, userData, onSave }) => { const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); - - // Define editable profile fields + const profileAttributes: AttributeDefinition[] = [ - { - name: 'fullName', - type: 'string', - label: 'Vollständiger Name', - description: 'Ihr vollständiger Name', - required: false, - placeholder: 'Max Mustermann' - }, - { - name: 'email', - type: 'email', - label: 'E-Mail-Adresse', - description: 'Ihre E-Mail-Adresse für Benachrichtigungen', - required: true, - placeholder: 'name@example.com' - }, - { - name: 'language', - type: 'select', - label: 'Sprache', - description: 'Anzeigesprache der Anwendung', - required: true, - options: [ - { value: 'de', label: 'Deutsch' }, - { value: 'en', label: 'English' }, - { value: 'fr', label: 'Français' } - ] - } + { name: 'fullName', type: 'string', label: 'Vollstaendiger Name', description: 'Ihr vollstaendiger Name', required: false, placeholder: 'Max Mustermann' }, + { name: 'email', type: 'email', label: 'E-Mail-Adresse', description: 'Ihre E-Mail-Adresse fuer Benachrichtigungen', required: true, placeholder: 'name@example.com' }, + { name: 'language', type: 'select', label: 'Sprache', description: 'Anzeigesprache der Anwendung', required: true, options: [{ value: 'de', label: 'Deutsch' }, { value: 'en', label: 'English' }, { value: 'fr', label: 'Français' }] }, ]; - + const handleSubmit = async (formData: any) => { setIsSaving(true); setError(null); @@ -72,9 +59,9 @@ const ProfileEditModal: React.FC = ({ isOpen, onClose, us setIsSaving(false); } }; - + if (!isOpen) return null; - + return (
e.stopPropagation()}> @@ -84,21 +71,231 @@ const ProfileEditModal: React.FC = ({ isOpen, onClose, us
{error &&
{error}
} - +
); }; +// ============================================================================= +// VOICE SETTINGS TAB +// ============================================================================= + +interface VoiceMapEntry { language: string; voiceName: string; } + +const VoiceSettingsTab: React.FC = () => { + const { request } = useApiRequest(); + + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [testing, setTesting] = useState(null); + const [error, setError] = useState(null); + 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'); + const [addVoices, setAddVoices] = useState([]); + const [addVoiceName, setAddVoiceName] = useState(''); + const [loadingVoices, setLoadingVoices] = useState(false); + + const _loadSettings = useCallback(async () => { + setLoading(true); + try { + const [prefsData, languagesData] = await Promise.all([ + request({ url: '/api/local/voice-preferences', method: 'get' }), + request({ url: '/api/local/voice/languages', method: 'get' }), + ]); + + const langList = (languagesData as any)?.languages || []; + setLanguages(langList); + + const prefs = prefsData as any; + setSttLanguage(prefs?.sttLanguage || 'de-DE'); + + const map: Record = prefs?.ttsVoiceMap || {}; + const entries: VoiceMapEntry[] = Object.entries(map).map(([lang, cfg]) => ({ + language: lang, + voiceName: typeof cfg === 'string' ? cfg : (cfg as any)?.voiceName || '', + })); + setVoiceMap(entries); + } catch (err: any) { + setError(err.message || 'Fehler beim Laden der Voice-Einstellungen'); + } finally { + setLoading(false); + } + }, [request]); + + useEffect(() => { _loadSettings(); }, [_loadSettings]); + + const _loadVoicesForLanguage = useCallback(async (lang: string) => { + setLoadingVoices(true); + try { + const result = await request({ url: '/api/local/voice/voices', method: 'get', params: { language: lang } }); + setAddVoices((result as any)?.voices || []); + setAddVoiceName(''); + } catch { setAddVoices([]); } + finally { setLoadingVoices(false); } + }, [request]); + + useEffect(() => { _loadVoicesForLanguage(addLanguage); }, [addLanguage, _loadVoicesForLanguage]); + + const _handleAddEntry = useCallback(() => { + if (!addLanguage) return; + const exists = voiceMap.some(e => e.language === addLanguage); + if (exists) { + setVoiceMap(prev => prev.map(e => e.language === addLanguage ? { ...e, voiceName: addVoiceName } : e)); + } else { + setVoiceMap(prev => [...prev, { language: addLanguage, voiceName: addVoiceName }]); + } + setAddVoiceName(''); + }, [addLanguage, addVoiceName, voiceMap]); + + const _handleRemoveEntry = useCallback((lang: string) => { + setVoiceMap(prev => prev.filter(e => e.language !== lang)); + }, []); + + const _handleSave = useCallback(async () => { + setSaving(true); + setError(null); + setSuccess(null); + try { + const mapObj: Record = {}; + voiceMap.forEach(e => { mapObj[e.language] = { voiceName: e.voiceName || '' }; }); + await request({ + url: '/api/local/voice-preferences', + method: 'put', + data: { sttLanguage, ttsLanguage: sttLanguage, ttsVoiceMap: mapObj }, + }); + setSuccess('Einstellungen gespeichert'); + setTimeout(() => setSuccess(null), 3000); + await _loadSettings(); + } catch (err: any) { + setError(err.message || 'Fehler beim Speichern'); + } finally { + setSaving(false); + } + }, [request, voiceMap, sttLanguage, _loadSettings]); + + const _handleTestVoice = useCallback(async (lang: string, voice: string) => { + setTesting(lang); + try { + const result: any = await request({ + url: '/api/local/voice/test', + method: 'post', + data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` }, + }); + if (result?.success && result?.audio) { + const audio = new Audio(`data:audio/mp3;base64,${result.audio}`); + audio.play(); + } + } catch { setError('Stimmtest fehlgeschlagen'); } + finally { setTesting(null); } + }, [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; + + if (loading) return
Einstellungen werden geladen...
; + + return ( + <> + {error &&
{error}
} + {success &&
{success}
} + +
+

STT-Sprache (Spracheingabe)

+
+
+ +

Wird fuer die Sprache-zu-Text-Erkennung verwendet.

+
+
+ +
+
+
+ +
+

TTS-Stimmen (Sprachausgabe)

+

+ Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden. +

+ + {voiceMap.length === 0 ? ( +
+ Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet. +
+ ) : ( + + + + {voiceMap.map(entry => ( + + + + + + + ))} + +
SpracheStimme
{_getLanguageName(entry.language)}{entry.voiceName || 'Standard'} + + + +
+ )} + +
+
+ + +
+
+ + +
+ + +
+
+ + + + ); +}; + // ============================================================================= // SETTINGS PAGE // ============================================================================= @@ -107,266 +304,135 @@ export const SettingsPage: React.FC = () => { const { currentLanguage, setLanguage } = useLanguage(); const { user: currentUser, refetch: refetchUser } = useCurrentUser(); const { updateUser } = useUser(); - - const [theme, setTheme] = useState<'light' | 'dark'>( - () => (localStorage.getItem('theme') as 'light' | 'dark') || 'light' - ); + + const [activeTab, setActiveTab] = useState('profile'); + const [theme, setTheme] = useState<'light' | 'dark'>(() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light'); const [isProfileModalOpen, setIsProfileModalOpen] = useState(false); const [isSavingLanguage, setIsSavingLanguage] = useState(false); const [languageError, setLanguageError] = useState(null); - - // Handle theme change + const handleThemeChange = (newTheme: 'light' | 'dark') => { setTheme(newTheme); localStorage.setItem('theme', newTheme); - - if (newTheme === 'dark') { - document.documentElement.classList.add('dark-theme'); - document.documentElement.classList.remove('light-theme'); - } else { - document.documentElement.classList.add('light-theme'); - document.documentElement.classList.remove('dark-theme'); - } + if (newTheme === 'dark') { document.documentElement.classList.add('dark-theme'); document.documentElement.classList.remove('light-theme'); } + else { document.documentElement.classList.add('light-theme'); document.documentElement.classList.remove('dark-theme'); } document.documentElement.setAttribute('data-theme', newTheme); }; - - // Handle language change - save to backend and update cache + const handleLanguageChange = useCallback(async (newLanguage: 'de' | 'en' | 'fr') => { if (!currentUser?.id || !currentUser?.username) return; - setIsSavingLanguage(true); setLanguageError(null); - try { - // 1. Build full user object for update (backend requires full User model) - const userUpdateData = { - id: currentUser.id, - username: currentUser.username, - email: currentUser.email, - fullName: currentUser.fullName, - language: newLanguage, - enabled: currentUser.enabled ?? true, - authenticationAuthority: currentUser.authenticationAuthority || 'local' - }; - - // 2. Save to backend - await updateUser(currentUser.id, userUpdateData); - - // 3. Update sessionStorage cache + await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: currentUser.email, fullName: currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' }); const cachedUser = getUserDataCache(); - if (cachedUser) { - setUserDataCache({ ...cachedUser, language: newLanguage }); - } - - // 4. Update UI language context + if (cachedUser) setUserDataCache({ ...cachedUser, language: newLanguage }); setLanguage(newLanguage); - - // 5. Dispatch event to notify other components window.dispatchEvent(new CustomEvent('userInfoUpdated')); - - console.log('Language updated successfully to:', newLanguage); - } catch (err: any) { - console.error('Failed to update language:', err); - setLanguageError('Sprache konnte nicht gespeichert werden'); - } finally { - setIsSavingLanguage(false); - } + } catch { setLanguageError('Sprache konnte nicht gespeichert werden'); } + finally { setIsSavingLanguage(false); } }, [currentUser, updateUser, setLanguage]); - - // Handle profile save + const handleProfileSave = useCallback(async (formData: any) => { if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet'); - - // Get the new language (from form or current user) const newLanguage = formData.language || currentUser.language || 'de'; - - // Build full user object for update (backend requires full User model) - const userUpdateData = { - id: currentUser.id, - username: currentUser.username, - email: formData.email || currentUser.email, - fullName: formData.fullName || currentUser.fullName, - language: newLanguage, - enabled: currentUser.enabled ?? true, - authenticationAuthority: currentUser.authenticationAuthority || 'local' - }; - - // Update user via API - const updatedUser = await updateUser(currentUser.id, userUpdateData); - - // Update sessionStorage cache + const updatedUser = await updateUser(currentUser.id, { id: currentUser.id, username: currentUser.username, email: formData.email || currentUser.email, fullName: formData.fullName || currentUser.fullName, language: newLanguage, enabled: currentUser.enabled ?? true, authenticationAuthority: currentUser.authenticationAuthority || 'local' }); const cachedUser = getUserDataCache(); - if (cachedUser) { - setUserDataCache({ - ...cachedUser, - fullName: updatedUser.fullName || cachedUser.fullName, - email: updatedUser.email || cachedUser.email, - language: newLanguage - }); - } - - // Update UI language if changed - if (newLanguage !== currentLanguage) { - setLanguage(newLanguage as 'de' | 'en' | 'fr'); - } - - // Refetch user data - if (refetchUser) { - await refetchUser(); - } - - // Dispatch event to notify other components (e.g., sidebar) + if (cachedUser) setUserDataCache({ ...cachedUser, fullName: updatedUser.fullName || cachedUser.fullName, email: updatedUser.email || cachedUser.email, language: newLanguage }); + if (newLanguage !== currentLanguage) setLanguage(newLanguage as 'de' | 'en' | 'fr'); + if (refetchUser) await refetchUser(); window.dispatchEvent(new CustomEvent('userInfoUpdated')); - }, [currentUser, updateUser, refetchUser, currentLanguage, setLanguage]); - + return (

Einstellungen

-

Persönliche Einstellungen und Präferenzen

+

Persoenliche Einstellungen und Praeferenzen

- + + +
- {/* Darstellung */} -
-

Darstellung

- -
-
- -

- Wähle zwischen hellem und dunklem Design. -

-
-
-
- - + {activeTab === 'profile' && ( + <> +
+

Konto

+
+
+ +

Aendern Sie Ihren Namen und Ihre E-Mail-Adresse.

+
+
+ +
+
+ {currentUser && ( +
+
Benutzername{currentUser.username}
+
Name{currentUser.fullName || '-'}
+
E-Mail{currentUser.email || '-'}
+
+ )} +
+
+

Ueber

+
+
Version2.0.0
+
Build2026.03.23
+
+
+ + )} + + {activeTab === 'appearance' && ( +
+

Darstellung

+
+

Waehlen Sie zwischen hellem und dunklem Design.

+
+
+ + +
-
- -
-
- -

- Wähle die Anzeigesprache der Anwendung. - {languageError && {languageError}} -

-
-
- - {isSavingLanguage && Speichern...} -
-
-
- - {/* Konto */} -
-

Konto

- -
-
- -

- Ändere deinen Namen und E-Mail-Adresse. -

-
-
- -
-
- - {/* Current user info display */} - {currentUser && ( -
-
- Benutzername - {currentUser.username} -
-
- Name - {currentUser.fullName || '-'} -
-
- E-Mail - {currentUser.email || '-'} +
+

Waehlen Sie die Sprache der Benutzeroberflaeche.{languageError && {languageError}}

+
+ + {isSavingLanguage && Speichern...}
- )} -
- - {/* Datenschutz */} -
-

Datenschutz

- -
-
- -

- Data export, portability and account deletion. -

+
+ )} + + {activeTab === 'voice' && } + + {activeTab === 'privacy' && ( +
+

Datenschutz

+
+

Datenexport, Portabilitaet und Kontoloeschung.

+
GDPR oeffnen
-
- - Open GDPR page - -
-
- - - {/* Info */} -
-

Über

- -
-
- Version - 2.0.0 -
-
- Build - 2026.01.20 -
-
-
+ + )} - - {/* Profile Edit Modal */} - setIsProfileModalOpen(false)} - userData={currentUser} - onSave={handleProfileSave} - /> + + setIsProfileModalOpen(false)} userData={currentUser} onSave={handleProfileSave} /> ); }; diff --git a/src/pages/views/commcoach/CommcoachSettingsView.tsx b/src/pages/views/commcoach/CommcoachSettingsView.tsx index 47613db..f7c056a 100644 --- a/src/pages/views/commcoach/CommcoachSettingsView.tsx +++ b/src/pages/views/commcoach/CommcoachSettingsView.tsx @@ -1,47 +1,30 @@ /** * CommCoach Settings View - * - * User profile settings: voice preferences, reminders, email notifications. + * + * Coaching-specific settings: reminders, email notifications, stats. + * Voice/language settings are in user-level settings (/settings -> "Stimme & Sprache"). */ import React, { useState, useEffect, useCallback } from 'react'; +import { Link } from 'react-router-dom'; import { useApiRequest } from '../../../hooks/useApi'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; -import api from '../../../api'; import { getProfileApi, updateProfileApi, - getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi, type CoachingUserProfile, } from '../../../api/commcoachApi'; import styles from './CommcoachSettingsView.module.css'; -async function _syncSharedVoicePreferences(lang: string, voice?: string): Promise { - try { - await api.put('/api/local/voice-preferences', { - sttLanguage: lang, - ttsLanguage: lang, - ttsVoice: voice ?? null, - }); - } catch { - // Silent fallback — shared prefs sync is best-effort - } -} - export const CommcoachSettingsView: React.FC = () => { const { request } = useApiRequest(); const instanceId = useInstanceId(); const [profile, setProfile] = useState(null); - const [languages, setLanguages] = useState([]); - const [voices, setVoices] = useState([]); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(null); - const [language, setLanguage] = useState('de-DE'); - const [voiceId, setVoiceId] = useState(''); const [reminderEnabled, setReminderEnabled] = useState(false); const [reminderTime, setReminderTime] = useState('09:00'); const [emailEnabled, setEmailEnabled] = useState(true); @@ -51,23 +34,13 @@ export const CommcoachSettingsView: React.FC = () => { const loadData = async () => { setLoading(true); try { - const [profileData, languagesData] = await Promise.all([ - getProfileApi(request, instanceId), - getVoiceLanguagesApi(request, instanceId), - ]); + const profileData = await getProfileApi(request, instanceId); setProfile(profileData); - setLanguages(languagesData || []); - if (profileData) { - setLanguage(profileData.preferredLanguage || 'de-DE'); - setVoiceId(profileData.preferredVoice || ''); setReminderEnabled(profileData.dailyReminderEnabled || false); setReminderTime(profileData.dailyReminderTime || '09:00'); setEmailEnabled(profileData.emailSummaryEnabled !== false); } - - const voicesData = await getVoiceVoicesApi(request, instanceId, profileData?.preferredLanguage || 'de-DE'); - setVoices(voicesData || []); } catch (err: any) { setError(err.message || 'Fehler beim Laden'); } finally { @@ -77,16 +50,6 @@ export const CommcoachSettingsView: React.FC = () => { loadData(); }, [request, instanceId]); - const handleLanguageChange = useCallback(async (newLang: string) => { - setLanguage(newLang); - if (!instanceId) return; - try { - const voicesData = await getVoiceVoicesApi(request, instanceId, newLang); - setVoices(voicesData || []); - setVoiceId(''); - } catch { /* ignore */ } - }, [request, instanceId]); - const handleSave = useCallback(async () => { if (!instanceId) return; setSaving(true); @@ -94,16 +57,11 @@ export const CommcoachSettingsView: React.FC = () => { setSuccess(null); try { const updated = await updateProfileApi(request, instanceId, { - preferredLanguage: language, - preferredVoice: voiceId || null, dailyReminderEnabled: reminderEnabled, dailyReminderTime: reminderTime, emailSummaryEnabled: emailEnabled, }); setProfile(updated); - - _syncSharedVoicePreferences(language, voiceId || undefined); - setSuccess('Einstellungen gespeichert'); setTimeout(() => setSuccess(null), 3000); } catch (err: any) { @@ -111,27 +69,7 @@ export const CommcoachSettingsView: React.FC = () => { } finally { setSaving(false); } - }, [request, instanceId, language, voiceId, reminderEnabled, reminderTime, emailEnabled]); - - const handleTestVoice = useCallback(async () => { - if (!instanceId) return; - setTesting(true); - try { - const result = await testVoiceApi(request, instanceId, { - language, - voiceId: voiceId || undefined, - }); - if (result.success && result.audio) { - const audioData = `data:audio/mp3;base64,${result.audio}`; - const audio = new Audio(audioData); - audio.play(); - } - } catch (err: any) { - setError('Sprachtest fehlgeschlagen'); - } finally { - setTesting(false); - } - }, [request, instanceId, language, voiceId]); + }, [request, instanceId, reminderEnabled, reminderTime, emailEnabled]); if (loading) { return
Einstellungen werden geladen...
; @@ -144,107 +82,46 @@ export const CommcoachSettingsView: React.FC = () => { {error &&
{error}
} {success &&
{success}
} - {/* Voice Settings */}
-

Sprache und Stimme

- -
- - -
- -
- -
- - -
-
+

Stimme & Sprache

+

+ Stimme und Sprache werden zentral in den Benutzereinstellungen konfiguriert. +

+ {}} style={{ fontSize: '0.85rem', color: 'var(--primary-color, #2563eb)' }}> + Benutzereinstellungen oeffnen (Tab "Stimme & Sprache") +
- {/* Reminder Settings */}

Erinnerungen

-
- {reminderEnabled && (
- setReminderTime(e.target.value)} - /> + setReminderTime(e.target.value)} />
)} -
- {/* Stats */} {profile && (

Statistik

-
- {profile.totalSessions} - Sessions gesamt -
-
- {profile.totalMinutes} - Minuten gesamt -
-
- {profile.streakDays} - Aktueller Streak -
-
- {profile.longestStreak} - Laengster Streak -
+
{profile.totalSessions}Sessions gesamt
+
{profile.totalMinutes}Minuten gesamt
+
{profile.streakDays}Aktueller Streak
+
{profile.longestStreak}Laengster Streak
)} diff --git a/src/pages/views/workspace/WorkspaceGeneralSettings.module.css b/src/pages/views/workspace/WorkspaceGeneralSettings.module.css new file mode 100644 index 0000000..a44d272 --- /dev/null +++ b/src/pages/views/workspace/WorkspaceGeneralSettings.module.css @@ -0,0 +1,16 @@ +.settings { padding: 1rem; max-width: 640px; } +.heading { margin: 0 0 1.5rem; font-size: 1.25rem; font-weight: 600; color: var(--text-primary, #1a1a1a); } +.loading { padding: 2rem; text-align: center; color: #999; } +.error { background: #fef2f2; border: 1px solid #fecaca; color: #dc2626; padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.875rem; } +.success { background: #f0fdf4; border: 1px solid #bbf7d0; color: #16a34a; padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.875rem; } +.section { background: var(--surface-color, #fff); border: 1px solid var(--border-color, #e0e0e0); border-radius: 10px; padding: 1.25rem; margin-bottom: 1.5rem; } +.sectionTitle { margin: 0 0 1rem; font-size: 0.95rem; font-weight: 600; } +.field { margin-bottom: 1rem; } +.label { display: block; font-size: 0.875rem; font-weight: 500; margin-bottom: 0.35rem; } +.input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid var(--border-color, #d0d0d0); border-radius: 6px; font-size: 0.875rem; background: var(--bg-primary, #fff); color: var(--text-primary, #1a1a1a); } +.input:focus { outline: none; border-color: var(--primary-color, #2563eb); box-shadow: 0 0 0 2px rgba(37,99,235,0.1); } +.removeBtn { background: none; border: none; color: #dc2626; cursor: pointer; font-size: 0.8rem; padding: 0.25rem 0.5rem; } +.removeBtn:hover { text-decoration: underline; } +.saveBtn { padding: 0.625rem 1.5rem; background: var(--primary-color, #2563eb); color: #fff; border: none; border-radius: 6px; font-size: 0.875rem; font-weight: 600; cursor: pointer; } +.saveBtn:hover { opacity: 0.9; } +.saveBtn:disabled { opacity: 0.5; cursor: not-allowed; } diff --git a/src/pages/views/workspace/WorkspaceGeneralSettings.tsx b/src/pages/views/workspace/WorkspaceGeneralSettings.tsx index 901a8fb..8680b3e 100644 --- a/src/pages/views/workspace/WorkspaceGeneralSettings.tsx +++ b/src/pages/views/workspace/WorkspaceGeneralSettings.tsx @@ -6,7 +6,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useApiRequest } from '../../../hooks/useApi'; -import styles from './WorkspaceSettings.module.css'; +import styles from './WorkspaceGeneralSettings.module.css'; interface GeneralSettingsProps { instanceId: string; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index e349e24..9d91a75 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -78,8 +78,9 @@ export const WorkspaceInput: React.FC = ({ const [autocompleteFilter, setAutocompleteFilter] = useState(''); const [treeDropOver, setTreeDropOver] = useState(false); const [voiceActive, setVoiceActive] = useState(false); - const [voiceLanguage, setVoiceLanguage] = useState(() => localStorage.getItem('workspace_stt_lang') || 'de-DE'); + const [voiceLanguage, setVoiceLanguage] = useState('de-DE'); const [showLangPicker, setShowLangPicker] = useState(false); + const _sttPrefsLoaded = useRef(false); const [attachedFileIds, setAttachedFileIds] = useState([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState([]); @@ -89,8 +90,13 @@ export const WorkspaceInput: React.FC = ({ const currentInterimRef = useRef(''); useEffect(() => { - localStorage.setItem('workspace_stt_lang', voiceLanguage); - }, [voiceLanguage]); + if (_sttPrefsLoaded.current) return; + _sttPrefsLoaded.current = true; + fetch('/api/local/voice-preferences', { credentials: 'include' }) + .then(r => r.ok ? r.json() : null) + .then(data => { if (data?.sttLanguage) setVoiceLanguage(data.sttLanguage); }) + .catch(() => {}); + }, []); const _extractFileRefs = useCallback( (text: string): string[] => { @@ -679,7 +685,11 @@ export const WorkspaceInput: React.FC = ({ {_STT_LANGUAGES.map(lang => (
{ setVoiceLanguage(lang.code); setShowLangPicker(false); }} + onClick={() => { + setVoiceLanguage(lang.code); + setShowLangPicker(false); + fetch('/api/local/voice-preferences', { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sttLanguage: lang.code }) }).catch(() => {}); + }} style={{ padding: '8px 12px', cursor: 'pointer', fontSize: 13, background: lang.code === voiceLanguage ? 'var(--primary-color, #1976d2)' : 'transparent', diff --git a/src/pages/views/workspace/WorkspaceSettings.module.css b/src/pages/views/workspace/WorkspaceSettings.module.css deleted file mode 100644 index 8138b1e..0000000 --- a/src/pages/views/workspace/WorkspaceSettings.module.css +++ /dev/null @@ -1,173 +0,0 @@ -.settings { - padding: 1rem; - max-width: 600px; -} - -.heading { - font-size: 1.2rem; - font-weight: 600; - margin-bottom: 1.5rem; - color: var(--text-primary, #333); -} - -.loading { - padding: 2rem; - text-align: center; - color: var(--text-secondary, #666); -} - -.error { - padding: 0.5rem 0.75rem; - background: #fde8e8; - color: var(--color-error, #d32f2f); - border-radius: 6px; - margin-bottom: 1rem; - font-size: 0.85rem; -} - -.success { - padding: 0.5rem 0.75rem; - background: #e8f5e9; - color: #2e7d32; - border-radius: 6px; - margin-bottom: 1rem; - font-size: 0.85rem; -} - -.section { - margin-bottom: 2rem; -} - -.sectionTitle { - font-size: 1rem; - font-weight: 600; - margin-bottom: 0.75rem; - color: var(--text-primary, #333); -} - -.field { - margin-bottom: 0.75rem; -} - -.label { - display: block; - font-size: 0.85rem; - font-weight: 500; - margin-bottom: 0.3rem; - color: var(--text-primary, #333); -} - -.select, .input { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border-color, #ddd); - border-radius: 6px; - font-size: 0.9rem; - background: var(--bg-input, #fff); - color: var(--text-primary, #333); -} - -.voiceRow { - display: flex; - gap: 0.5rem; -} - -.voiceRow .select { - flex: 1; -} - -.testBtn, .addBtn, .removeBtn { - padding: 0.5rem 1rem; - background: var(--primary-color, #F25843); - color: #fff; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.85rem; - white-space: nowrap; -} - -.testBtn:hover:not(:disabled), -.addBtn:hover:not(:disabled) { filter: brightness(1.08); } - -.testBtn:disabled, -.addBtn:disabled { - background: var(--color-medium-gray, #ccc); - color: var(--text-secondary, #888); - cursor: not-allowed; - opacity: 0.8; -} - -.removeBtn { - background: transparent; - color: var(--color-error, #d32f2f); - padding: 0.3rem 0.6rem; - font-size: 0.8rem; - border: 1px solid var(--color-error, #d32f2f); -} - -.removeBtn:hover { background: #fde8e8; } - -.voiceTable { - width: 100%; - border-collapse: collapse; - margin-top: 0.75rem; -} - -.voiceTable th, -.voiceTable td { - text-align: left; - padding: 0.4rem 0.6rem; - border-bottom: 1px solid var(--border-color, #e0e0e0); - font-size: 0.85rem; -} - -.voiceTable th { - font-weight: 600; - color: var(--text-secondary, #666); - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; -} - -.emptyHint { - color: var(--text-secondary, #999); - font-size: 0.85rem; - font-style: italic; - padding: 0.5rem 0; -} - -.saveBtn { - width: 100%; - padding: 0.6rem; - background: var(--primary-color, #F25843); - color: #fff; - border: none; - border-radius: 6px; - cursor: pointer; - font-size: 0.9rem; - font-weight: 500; -} - -.saveBtn:hover:not(:disabled) { filter: brightness(1.08); } -.saveBtn:disabled { - background: var(--color-medium-gray, #ccc); - color: var(--text-secondary, #888); - cursor: not-allowed; - opacity: 0.8; -} - -.backBtn { - background: none; - border: none; - cursor: pointer; - font-size: 0.85rem; - color: var(--primary-color, #1976d2); - padding: 0; - margin-bottom: 1rem; - display: flex; - align-items: center; - gap: 4px; -} - -.backBtn:hover { text-decoration: underline; } diff --git a/src/pages/views/workspace/WorkspaceSettings.tsx b/src/pages/views/workspace/WorkspaceSettings.tsx deleted file mode 100644 index 11e9c46..0000000 --- a/src/pages/views/workspace/WorkspaceSettings.tsx +++ /dev/null @@ -1,280 +0,0 @@ -/** - * WorkspaceSettings -- Voice preferences per language. - * - * Allows the user to configure a preferred voice for each TTS language. - * Language detection is automatic; this page lets users override the - * default Google Cloud voice for specific languages. - */ - -import React, { useState, useEffect, useCallback } from 'react'; -import { useApiRequest } from '../../../hooks/useApi'; -import styles from './WorkspaceSettings.module.css'; - -interface VoiceMapEntry { - language: string; - voiceName: string; -} - -interface WorkspaceSettingsProps { - instanceId: string; -} - -export const WorkspaceSettings: React.FC = ({ instanceId }) => { - const { request } = useApiRequest(); - - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [testing, setTesting] = useState(null); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - - const [languages, setLanguages] = useState([]); - const [voiceMap, setVoiceMap] = useState([]); - - const [addLanguage, setAddLanguage] = useState('de-DE'); - const [addVoices, setAddVoices] = useState([]); - const [addVoiceName, setAddVoiceName] = useState(''); - const [loadingVoices, setLoadingVoices] = useState(false); - - const _loadSettings = useCallback(async () => { - if (!instanceId) return; - setLoading(true); - try { - const [settingsData, languagesData] = await Promise.all([ - request({ url: `/api/workspace/${instanceId}/settings/voice`, method: 'get' }), - request({ url: `/api/workspace/${instanceId}/voice/languages`, method: 'get' }), - ]); - - const langList = (languagesData as any)?.languages || []; - setLanguages(langList); - - const map: Record = (settingsData as any)?.ttsVoiceMap || {}; - const entries: VoiceMapEntry[] = Object.entries(map).map(([lang, cfg]) => ({ - language: lang, - voiceName: typeof cfg === 'string' ? cfg : (cfg as any)?.voiceName || '', - })); - setVoiceMap(entries); - } catch (err: any) { - setError(err.message || 'Fehler beim Laden der Einstellungen'); - } finally { - setLoading(false); - } - }, [request, instanceId]); - - useEffect(() => { _loadSettings(); }, [_loadSettings]); - - const _loadVoicesForLanguage = useCallback(async (lang: string) => { - if (!instanceId) return; - setLoadingVoices(true); - try { - const result = await request({ - url: `/api/workspace/${instanceId}/voice/voices`, - method: 'get', - params: { language: lang }, - }); - setAddVoices((result as any)?.voices || []); - setAddVoiceName(''); - } catch { - setAddVoices([]); - } finally { - setLoadingVoices(false); - } - }, [request, instanceId]); - - useEffect(() => { _loadVoicesForLanguage(addLanguage); }, [addLanguage, _loadVoicesForLanguage]); - - const _handleAddEntry = useCallback(() => { - if (!addLanguage) return; - const exists = voiceMap.some(e => e.language === addLanguage); - if (exists) { - setVoiceMap(prev => prev.map(e => - e.language === addLanguage ? { ...e, voiceName: addVoiceName } : e - )); - } else { - setVoiceMap(prev => [...prev, { language: addLanguage, voiceName: addVoiceName }]); - } - setAddVoiceName(''); - }, [addLanguage, addVoiceName, voiceMap]); - - const _handleRemoveEntry = useCallback((lang: string) => { - setVoiceMap(prev => prev.filter(e => e.language !== lang)); - }, []); - - const _handleSave = useCallback(async () => { - if (!instanceId) return; - setSaving(true); - setError(null); - setSuccess(null); - try { - const mapObj: Record = {}; - voiceMap.forEach(e => { - mapObj[e.language] = { voiceName: e.voiceName || '' }; - }); - const putResult = await request({ - url: `/api/workspace/${instanceId}/settings/voice`, - method: 'put', - data: { ttsVoiceMap: mapObj }, - }); - if ((putResult as any)?.error) { - setError((putResult as any).error); - return; - } - setSuccess('Einstellungen gespeichert'); - setTimeout(() => setSuccess(null), 3000); - await _loadSettings(); - } catch (err: any) { - setError(err.message || 'Fehler beim Speichern'); - } finally { - setSaving(false); - } - }, [request, instanceId, voiceMap]); - - const _handleTestVoice = useCallback(async (lang: string, voice: string) => { - if (!instanceId) return; - setTesting(lang); - try { - const result: any = await request({ - url: `/api/workspace/${instanceId}/voice/test`, - method: 'post', - data: { language: lang, voiceId: voice || undefined, text: `Hallo, das ist ein Stimmtest in ${lang}.` }, - }); - if (result?.success && result?.audio) { - const audio = new Audio(`data:audio/mp3;base64,${result.audio}`); - audio.play(); - } - } catch { - setError('Stimmtest fehlgeschlagen'); - } finally { - setTesting(null); - } - }, [request, instanceId]); - - const _getLanguageName = useCallback((code: string) => { - const found = languages.find((l: any) => (l.code || l) === code); - return found?.name || found?.code || code; - }, [languages]); - - if (loading) { - return
Einstellungen werden geladen...
; - } - - 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; - - return ( -
-

Stimmeneinstellungen

- - {error &&
{error}
} - {success &&
{success}
} - -
-

Konfigurierte Stimmen pro Sprache

-

- Die Sprache wird automatisch erkannt. Hier kann pro Sprache eine bevorzugte Stimme festgelegt werden. -

- - {voiceMap.length === 0 ? ( -
- Keine Stimmen konfiguriert. Die Standardstimme wird fuer alle Sprachen verwendet. -
- ) : ( - - - - - - - - - - - {voiceMap.map(entry => ( - - - - - - - ))} - -
SpracheStimme
{_getLanguageName(entry.language)}{entry.voiceName || 'Standard'} - - - -
- )} -
- -
-

Stimme hinzufuegen / aendern

- -
- - -
- -
- -
- - -
-
- - -
- - -
- ); -}; - -export default WorkspaceSettings; diff --git a/src/pages/views/workspace/WorkspaceSettingsPage.tsx b/src/pages/views/workspace/WorkspaceSettingsPage.tsx index 6a22317..8f25088 100644 --- a/src/pages/views/workspace/WorkspaceSettingsPage.tsx +++ b/src/pages/views/workspace/WorkspaceSettingsPage.tsx @@ -1,21 +1,19 @@ /** * WorkspaceSettingsPage -- Tabbed settings for the AI Workspace. * - * First tab: Voice / Language (WorkspaceSettings). - * Additional tabs can be added here as needed. + * Tabs: General settings, Neutralization. + * Voice settings are now in user-level settings (/settings -> "Stimme & Sprache"). */ import React, { useState } from 'react'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; -import { WorkspaceSettings } from './WorkspaceSettings'; import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings'; import NeutralizationPanel from './NeutralizationPanel'; -type SettingsTab = 'general' | 'voice' | 'neutralization'; +type SettingsTab = 'general' | 'neutralization'; const _TABS: { key: SettingsTab; label: string }[] = [ { key: 'general', label: 'Generelle Einstellungen' }, - { key: 'voice', label: 'Sprache & Stimme' }, { key: 'neutralization', label: 'Neutralisierung' }, ]; @@ -26,7 +24,7 @@ export const WorkspaceSettingsPage: React.FC = () => { if (!instanceId) { return (
- Keine Workspace-Instanz ausgewählt. + Keine Workspace-Instanz ausgewaehlt.
); } @@ -68,9 +66,6 @@ export const WorkspaceSettingsPage: React.FC = () => { {activeTab === 'general' && ( )} - {activeTab === 'voice' && ( - - )} {activeTab === 'neutralization' && ( )} From f5f6cad54299f20097855e3a4e0ab41b270de6fc Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 28 Mar 2026 16:58:55 +0100 Subject: [PATCH 03/11] fixing round 1 --- .../FolderTree/FolderTree.module.css | 7 + src/components/FolderTree/FolderTree.tsx | 56 +- .../Navigation/MandateNavigation.module.css | 21 + .../Navigation/MandateNavigation.tsx | 66 +- .../TreeNavigation/TreeNavigation.module.css | 16 + .../TreeNavigation/TreeNavigation.tsx | 7 + src/components/Navigation/UserSection.tsx | 21 +- src/components/OnboardingAssistant.tsx | 298 +++-- .../UnifiedDataBar/ChatsTab.module.css | 159 ++- src/components/UnifiedDataBar/ChatsTab.tsx | 296 ++++- .../UnifiedDataBar/FilesTab.module.css | 1 + src/components/UnifiedDataBar/FilesTab.tsx | 317 ++++- src/components/UnifiedDataBar/SourcesTab.tsx | 1125 ++++++++++++++++- .../UnifiedDataBar/UnifiedDataBar.tsx | 54 +- src/components/UnifiedDataBar/index.ts | 3 - src/hooks/useNavigation.ts | 1 + src/pages/Dashboard.tsx | 17 +- src/pages/Settings.tsx | 115 +- src/pages/Store.module.css | 3 + src/pages/Store.tsx | 73 +- src/pages/basedata/ConnectionsPage.tsx | 2 +- .../views/commcoach/CommcoachDossierView.tsx | 14 +- src/pages/views/workspace/ChatStream.tsx | 12 + .../views/workspace/ConversationList.tsx | 438 ------- src/pages/views/workspace/DataSourcePanel.tsx | 942 -------------- src/pages/views/workspace/FileBrowser.tsx | 252 ---- src/pages/views/workspace/WorkspaceInput.tsx | 23 +- src/pages/views/workspace/WorkspacePage.tsx | 68 +- src/pages/views/workspace/useWorkspace.ts | 4 + 29 files changed, 2397 insertions(+), 2014 deletions(-) delete mode 100644 src/pages/views/workspace/ConversationList.tsx delete mode 100644 src/pages/views/workspace/DataSourcePanel.tsx delete mode 100644 src/pages/views/workspace/FileBrowser.tsx diff --git a/src/components/FolderTree/FolderTree.module.css b/src/components/FolderTree/FolderTree.module.css index 52df54d..5fd26fa 100644 --- a/src/components/FolderTree/FolderTree.module.css +++ b/src/components/FolderTree/FolderTree.module.css @@ -146,7 +146,14 @@ font-size: 10px; color: var(--color-text-secondary, #999); flex-shrink: 0; +} + +.scopeIcons { + display: flex; + gap: 2px; + flex-shrink: 0; margin-left: auto; + align-items: center; } .rootActions { diff --git a/src/components/FolderTree/FolderTree.tsx b/src/components/FolderTree/FolderTree.tsx index 7e4860a..4332748 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -30,6 +30,8 @@ export interface FileNode { mimeType?: string; fileSize?: number; folderId?: string | null; + scope?: string; + neutralize?: boolean; } export interface TreeItem { @@ -62,6 +64,8 @@ export interface FolderTreeProps { onDeleteFiles?: (fileIds: string[]) => Promise; onDeleteFolders?: (folderIds: string[]) => Promise; onDownloadFolder?: (folderId: string, folderName: string) => Promise; + onScopeChange?: (fileId: string, newScope: string) => void; + onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; } /* ── Helpers ───────────────────────────────────────────────────────────── */ @@ -146,6 +150,22 @@ function _fileIcon(mime?: string): string { /* ── Selection context threaded through the tree ──────────────────────── */ +const _SCOPE_ICONS: Record = { + personal: '\uD83D\uDC64', + featureInstance: '\uD83D\uDC65', + mandate: '\uD83C\uDFE2', + global: '\uD83C\uDF10', +}; + +const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate']; + +const _SCOPE_LABELS: Record = { + personal: 'Persönlich', + featureInstance: 'Instanz', + mandate: 'Mandant', + global: 'Global', +}; + interface SelectionCtx { selectedItemIds: Set; selectedFileIds: string[]; @@ -156,6 +176,8 @@ interface SelectionCtx { onDeleteFile?: (fileId: string) => Promise; onDeleteFiles?: (fileIds: string[]) => Promise; onDeleteFolders?: (folderIds: string[]) => Promise; + onScopeChange?: (fileId: string, newScope: string) => void; + onNeutralizeToggle?: (fileId: string, newValue: boolean) => void; } /* ── File node (leaf) ─────────────────────────────────────────────────── */ @@ -232,6 +254,35 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { {(file.fileSize / 1024).toFixed(0)}K )} + {!renaming && file.scope != null && ( + + + + + )} {!renaming && ( {sel.onRenameFile && !multiSelected && ( @@ -517,6 +568,7 @@ export default function FolderTree({ expandedIds: externalExpandedIds, onToggleExpand, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onRefresh, onDownloadFolder, + onScopeChange, onNeutralizeToggle, }: FolderTreeProps) { const [internalExpandedIds, setInternalExpandedIds] = useState>(new Set()); const [rootDropOver, setRootDropOver] = useState(false); @@ -634,8 +686,10 @@ export default function FolderTree({ onDeleteFile, onDeleteFiles, onDeleteFolders, + onScopeChange, + onNeutralizeToggle, }; - }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders]); + }, [selectedItemIds, allFileIds, allFolderIds, _handleItemClick, _handleItemDragStart, onRenameFile, onDeleteFile, onDeleteFiles, onDeleteFolders, onScopeChange, onNeutralizeToggle]); const _handleRootDrop = useCallback(async (e: React.DragEvent) => { e.preventDefault(); diff --git a/src/components/Navigation/MandateNavigation.module.css b/src/components/Navigation/MandateNavigation.module.css index dcb8358..f3cf712 100644 --- a/src/components/Navigation/MandateNavigation.module.css +++ b/src/components/Navigation/MandateNavigation.module.css @@ -282,6 +282,27 @@ margin-top: 0.5rem; } +/* Rename button (inline, hover-visible via TreeNavigation nodeActions) */ +.renameButton { + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + padding: 0; + border: none; + border-radius: 3px; + background: transparent; + color: var(--text-tertiary, #888); + cursor: pointer; + transition: color 0.15s ease, background 0.15s ease; +} + +.renameButton:hover { + color: var(--primary-color, #2563eb); + background: var(--hover-bg, rgba(0, 0, 0, 0.06)); +} + /* Dark Theme */ :global(.dark-theme) .separator { background: var(--border-dark, #333); diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index e8cc3bc..9f9c1dc 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -20,7 +20,7 @@ * - Users, Mandates, Roles, ... */ -import React, { useMemo } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { useNavigation } from '../../hooks/useNavigation'; import type { DynamicBlock, @@ -31,8 +31,9 @@ import type { FeatureView } from '../../hooks/useNavigation'; import { getPageIcon } from '../../config/pageRegistry'; -import { FaSpinner } from 'react-icons/fa'; +import { FaSpinner, FaPen } from 'react-icons/fa'; import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation'; +import api from '../../api'; import styles from './MandateNavigation.module.css'; // ============================================================================= @@ -84,16 +85,32 @@ function featureViewToTreeNode(view: FeatureView): TreeNodeItem { * Convert a FeatureInstance to TreeNodeItem (with feature icon) * Instance node gets path to first view so clicking the instance name navigates to dashboard. * Shows the feature icon next to the instance name for visual distinction. + * If user is instance admin, a rename icon appears on hover. */ -function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent: string): TreeNodeItem { +function featureInstanceToTreeNode( + instance: FeatureInstance, + featureUiComponent: string, + onRename?: (instanceId: string, currentLabel: string) => void, +): TreeNodeItem { const children = instance.views.map(featureViewToTreeNode); + const renameAction = instance.isAdmin && onRename ? ( + + ) : undefined; + return { id: instance.id, label: instance.uiLabel, - icon: getPageIcon(featureUiComponent), // Use feature icon for instance + icon: getPageIcon(featureUiComponent), path: instance.views.length > 0 ? instance.views[0].uiPath : undefined, children, defaultExpanded: false, + actions: renameAction, }; } @@ -106,16 +123,18 @@ function featureInstanceToTreeNode(instance: FeatureInstance, featureUiComponent * Before: Mandate → Feature → Instance → Views * Now: Mandate → Instance (with feature icon) → Views */ -function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | null { +function navigationMandateToTreeNode( + mandate: NavigationMandate, + onRename?: (instanceId: string, currentLabel: string) => void, +): TreeNodeItem | null { if (mandate.features.length === 0) { return null; } - // Flatten: collect all instances from all features directly under mandate const instanceNodes: TreeNodeItem[] = []; for (const feature of mandate.features) { for (const instance of feature.instances) { - instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent)); + instanceNodes.push(featureInstanceToTreeNode(instance, feature.uiComponent, onRename)); } } @@ -134,9 +153,12 @@ function navigationMandateToTreeNode(mandate: NavigationMandate): TreeNodeItem | /** * Convert a DynamicBlock to array of TreeNodeItems (mandate nodes) */ -function dynamicBlockToTreeNodes(block: DynamicBlock): TreeNodeItem[] { +function dynamicBlockToTreeNodes( + block: DynamicBlock, + onRename?: (instanceId: string, currentLabel: string) => void, +): TreeNodeItem[] { return block.mandates - .map(navigationMandateToTreeNode) + .map((m) => navigationMandateToTreeNode(m, onRename)) .filter((node): node is TreeNodeItem => node !== null); } @@ -169,18 +191,19 @@ const EmptyState: React.FC = () => ( // ============================================================================= export const MandateNavigation: React.FC = () => { - // Fetch navigation from new API (blocks structure, already filtered by permissions) - const { blocks, loading } = useNavigation('de'); - - // Build navigation items from blocks - // Groups static items into collapsible containers: - // - "Meine Sicht": all non-admin static items (Übersicht, Einstellungen, Prompts, etc.) - // - "Administration": admin items, possibly with subgroups - // - Dynamic block (mandates) renders between them + const { blocks, loading, refresh } = useNavigation('de'); + + const _handleRename = useCallback((instanceId: string, currentLabel: string) => { + const newLabel = window.prompt('Neuer Name:', currentLabel); + if (!newLabel || newLabel.trim() === currentLabel) return; + api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() }) + .then(() => refresh()) + .catch((err: any) => alert('Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message))); + }, [refresh]); + const navigationItems: TreeItem[] = useMemo(() => { const items: TreeItem[] = []; - // Collect static items by category const meineSichtItems: NavigationItem[] = []; let adminItems: NavigationItem[] = []; let adminSubgroups: NavSubgroup[] = []; @@ -199,15 +222,13 @@ export const MandateNavigation: React.FC = () => { } } - // "Meine Sicht" - collapsible container for user-facing pages if (meineSichtItems.length > 0) { items.push(_staticItemsToTreeNode('meine-sicht', 'Meine Sicht', meineSichtItems, true)); } - // Dynamic block: mandates with feature instances for (const block of blocks) { if (block.type === 'dynamic') { - const mandateNodes = dynamicBlockToTreeNodes(block); + const mandateNodes = dynamicBlockToTreeNodes(block, _handleRename); if (mandateNodes.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); items.push(...mandateNodes); @@ -215,7 +236,6 @@ export const MandateNavigation: React.FC = () => { } } - // "Administration" - collapsible container for admin pages (with subgroup support) if (adminSubgroups.length > 0) { if (items.length > 0) items.push({ type: 'separator' }); const subgroupNodes: TreeNodeItem[] = adminSubgroups.map(sg => ({ @@ -236,7 +256,7 @@ export const MandateNavigation: React.FC = () => { } return items; - }, [blocks]); + }, [blocks, _handleRename]); // Check if user has any navigation (static or dynamic) const hasNavigation = blocks.length > 0; diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css index 5e9f57c..c7c0c7c 100644 --- a/src/components/Navigation/TreeNavigation/TreeNavigation.module.css +++ b/src/components/Navigation/TreeNavigation/TreeNavigation.module.css @@ -257,6 +257,22 @@ color: white; } +/* ============================================ */ +/* NODE ACTIONS (hover-reveal inline icons) */ +/* ============================================ */ + +.nodeActions { + display: none; + align-items: center; + gap: 0.25rem; + flex-shrink: 0; + margin-left: auto; +} + +.treeNode:hover .nodeActions { + display: flex; +} + /* ============================================ */ /* DARK THEME */ /* ============================================ */ diff --git a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx index babceee..e60cfac 100644 --- a/src/components/Navigation/TreeNavigation/TreeNavigation.tsx +++ b/src/components/Navigation/TreeNavigation/TreeNavigation.tsx @@ -47,6 +47,8 @@ export interface TreeNodeItem { level?: number; /** Data attribute for testing/identification */ dataId?: string; + /** Inline action element rendered at the end of the row (e.g. rename icon) */ + actions?: ReactNode; } export interface TreeSectionItem { @@ -219,6 +221,11 @@ const TreeNode: React.FC = ({ {node.badge} )} + {node.actions && ( + e.stopPropagation()}> + {node.actions} + + )} ); diff --git a/src/components/Navigation/UserSection.tsx b/src/components/Navigation/UserSection.tsx index 656976d..44697ae 100644 --- a/src/components/Navigation/UserSection.tsx +++ b/src/components/Navigation/UserSection.tsx @@ -8,6 +8,7 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useCurrentUser } from '../../hooks/useUsers'; import { NotificationBell } from '../NotificationBell'; +import { _isOnboardingHidden, _showOnboarding } from '../OnboardingAssistant'; import styles from './UserSection.module.css'; export const UserSection: React.FC = () => { @@ -16,6 +17,7 @@ export const UserSection: React.FC = () => { const [isLoggingOut, setIsLoggingOut] = useState(false); const [showMenu, setShowMenu] = useState(false); const [showLegalModal, setShowLegalModal] = useState(false); + const [onboardingHidden, setOnboardingHidden] = useState(() => _isOnboardingHidden()); const handleLogout = async () => { setIsLoggingOut(true); @@ -41,6 +43,13 @@ export const UserSection: React.FC = () => { setShowLegalModal(true); setShowMenu(false); }; + + const handleOnboarding = () => { + _showOnboarding(); + setOnboardingHidden(false); + navigate('/', { state: { showOnboarding: Date.now() } }); + setShowMenu(false); + }; if (!user) { return null; @@ -61,7 +70,7 @@ export const UserSection: React.FC = () => { + {onboardingHidden && ( + + )} +
@@ -170,34 +214,78 @@ const OnboardingAssistant: React.FC = ({
- {steps.map((step) => ( -
- - {step.completed ? '\u2713' : '\u25CB'} - -
-
- {step.label} -
-
- {step.description} + {steps.map((step, idx) => { + const isNextStep = !step.completed && steps.slice(0, idx).every(s => s.completed); + return ( +
+
+ + {step.completed ? '\u2713' : '\u25CB'} + +
+
+ {step.label} +
+
+ {step.description} +
+
+ {step.action && !step.completed && ( + {'\u2192'} + )}
+ {isNextStep && _CALLOUTS[step.id] && ( +
+ {_CALLOUTS[step.id]} +
+ )}
- {step.action && !step.completed && ( - {'\u2192'} - )} -
- ))} + ); + })} +
+ +
+ +
); diff --git a/src/components/UnifiedDataBar/ChatsTab.module.css b/src/components/UnifiedDataBar/ChatsTab.module.css index 5118b5b..008c846 100644 --- a/src/components/UnifiedDataBar/ChatsTab.module.css +++ b/src/components/UnifiedDataBar/ChatsTab.module.css @@ -20,6 +20,23 @@ color: var(--text-primary, #111); } +.createBtn { + padding: 6px 10px; + border: 1px solid var(--border-color, #d1d5db); + border-radius: 6px; + background: var(--accent, #4f46e5); + color: #fff; + cursor: pointer; + font-size: 1rem; + font-weight: 600; + line-height: 1; + transition: background 0.15s; +} + +.createBtn:hover { + background: var(--accent-hover, #4338ca); +} + .modeToggle { padding: 6px 8px; border: 1px solid var(--border-color, #d1d5db); @@ -33,12 +50,50 @@ background: var(--bg-active, #eef2ff); } -.loading { +/* ── Aktiv / Archiv filter tabs ── */ + +.filterTabs { + display: flex; + gap: 0; + border-bottom: 2px solid var(--border-color, #e5e7eb); +} + +.filterTab { + flex: 1; + padding: 6px 0; + font-size: 0.8rem; + font-weight: 600; + text-align: center; + border: none; + background: none; + cursor: pointer; + color: var(--text-secondary, #6b7280); + border-bottom: 2px solid transparent; + margin-bottom: -2px; + transition: color 0.15s, border-color 0.15s; +} + +.filterTab:hover { + color: var(--text-primary, #111); +} + +.filterTabActive { + color: var(--accent, #4f46e5); + border-bottom-color: var(--accent, #4f46e5); +} + +/* ── Loading / Empty ── */ + +.loading, +.emptyState { padding: 16px; text-align: center; color: var(--text-secondary, #6b7280); + font-size: 0.85rem; } +/* ── Chat list ── */ + .flatList, .tree { display: flex; @@ -46,33 +101,100 @@ } .chatItem { - padding: 8px 10px; + padding: 6px 10px; border-radius: 6px; cursor: pointer; display: flex; - justify-content: space-between; align-items: center; font-size: 0.85rem; + position: relative; + gap: 6px; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s; } .chatItem:hover { background: var(--bg-hover, rgba(0, 0, 0, 0.04)); } +.chatItemActive { + background: var(--primary-light, #eef2ff); + border-color: var(--accent, #4f46e5); + font-weight: 500; +} + +.chatItemActive:hover { + background: var(--primary-light, #eef2ff); +} + +.chatItemArchived { + opacity: 0.65; +} + +.chatDate { + font-size: 0.7rem; + color: var(--text-secondary, #9ca3af); + flex-shrink: 0; + min-width: 36px; +} + .chatLabel { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; + min-width: 0; } -.chatDate { - font-size: 0.75rem; - color: var(--text-secondary, #9ca3af); +/* ── Inline action icons (show on hover) ── */ + +.chatActions { + display: none; + gap: 2px; flex-shrink: 0; - margin-left: 8px; + margin-left: auto; + align-items: center; } +.chatItem:hover .chatActions { + display: flex; +} + +.actionBtn { + background: none; + border: none; + cursor: pointer; + padding: 2px 3px; + border-radius: 4px; + font-size: 0.75rem; + line-height: 1; + transition: background 0.15s; + opacity: 0.7; +} + +.actionBtn:hover { + background: rgba(0, 0, 0, 0.06); + opacity: 1; +} + +.actionBtnDanger:hover { + background: rgba(220, 38, 38, 0.1); +} + +.renameInput { + flex: 1; + min-width: 0; + font-size: 0.85rem; + padding: 2px 6px; + border-radius: 4px; + border: 1px solid var(--accent, #4f46e5); + outline: none; + background: var(--bg-input, #fff); + color: var(--text-primary, #111); +} + +/* ── Tree groups ── */ + .treeGroup { margin-bottom: 2px; } @@ -118,7 +240,8 @@ } @media (prefers-color-scheme: dark) { - .search { + .search, + .renameInput { background: var(--bg-input-dark, #1f2937); border-color: var(--border-dark, #374151); color: #f3f4f6; @@ -127,8 +250,28 @@ .treeGroupHeader:hover { background: rgba(255, 255, 255, 0.05); } + .chatItemActive, + .chatItemActive:hover { + background: rgba(79, 70, 229, 0.15); + border-color: var(--accent, #4f46e5); + } .treeGroupCount { background: #374151; color: #9ca3af; } + .actionBtn:hover { + background: rgba(255, 255, 255, 0.08); + } + .actionBtnDanger:hover { + background: rgba(220, 38, 38, 0.15); + } + .createBtn { + border-color: var(--border-dark, #374151); + } + .filterTabs { + border-bottom-color: var(--border-dark, #374151); + } + .filterTab:hover { + color: #f3f4f6; + } } diff --git a/src/components/UnifiedDataBar/ChatsTab.tsx b/src/components/UnifiedDataBar/ChatsTab.tsx index 9391756..cfb961e 100644 --- a/src/components/UnifiedDataBar/ChatsTab.tsx +++ b/src/components/UnifiedDataBar/ChatsTab.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; import styles from './ChatsTab.module.css'; @@ -6,9 +6,10 @@ import styles from './ChatsTab.module.css'; interface ChatItem { id: string; label: string; - updatedAt?: string; + updatedAt?: string | number; featureInstanceId?: string; featureCode?: string; + status?: string; } interface ChatGroup { @@ -18,24 +19,63 @@ interface ChatGroup { chats: ChatItem[]; } +type ChatFilter = 'active' | 'archived'; + interface ChatsTabProps { context: UdbContext; onSelectChat?: (chatId: string, featureInstanceId: string) => void; onDragStart?: (chatId: string, event: React.DragEvent) => void; + activeWorkflowId?: string; + onCreateNew?: () => void; + onRenameChat?: (chatId: string, newName: string) => void | Promise; + onDeleteChat?: (chatId: string) => void | Promise; } -const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart }) => { +function _formatRelativeTime(dateStr?: string | number): string { + if (!dateStr) return ''; + const d = typeof dateStr === 'number' ? new Date(dateStr * 1000) : new Date(dateStr); + if (isNaN(d.getTime())) return ''; + const now = new Date(); + const diffMs = now.getTime() - d.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + const diffH = Math.floor(diffMs / 3_600_000); + const diffDays = Math.floor(diffMs / 86_400_000); + + if (diffMin < 1) return 'gerade eben'; + if (diffMin < 60) return `${diffMin}m`; + if (diffH < 24) return `${diffH}h`; + if (diffDays === 1) return 'gestern'; + if (diffDays < 7) return `vor ${diffDays}d`; + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); +} + +const ChatsTab: React.FC = ({ + context, + onSelectChat, + onDragStart, + activeWorkflowId, + onCreateNew, + onRenameChat, + onDeleteChat, +}) => { const [groups, setGroups] = useState([]); const [flatMode, setFlatMode] = useState(false); const [search, setSearch] = useState(''); + const [filter, setFilter] = useState('active'); const [expandedGroups, setExpandedGroups] = useState>(new Set()); const [loading, setLoading] = useState(true); + const [editingId, setEditingId] = useState(null); + const [editName, setEditName] = useState(''); + const renameInputRef = useRef(null); const _loadChats = useCallback(async () => { setLoading(true); try { - const response = await api.get(`/api/workspace/${context.instanceId}/workflows`); - const workflows = response.data?.data || response.data || []; + const response = await api.get( + `/api/workspace/${context.instanceId}/workflows`, + { params: { includeArchived: true } }, + ); + const workflows = response.data?.workflows || response.data?.data || []; const groupMap = new Map(); for (const wf of workflows) { @@ -51,15 +91,20 @@ const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart groupMap.get(fiId)!.chats.push({ id: wf.id, label: wf.label || wf.name || `Chat ${wf.id.slice(0, 8)}`, - updatedAt: wf.updatedAt || wf.createdAt, + updatedAt: wf.updatedAt || wf.lastActivity || wf.startedAt, featureInstanceId: fiId, featureCode: wf.featureCode, + status: wf.status || 'active', }); } const sorted = Array.from(groupMap.values()); sorted.forEach(g => - g.chats.sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')), + 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; + }), ); setGroups(sorted); @@ -75,6 +120,19 @@ const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart useEffect(() => { _loadChats(); }, [_loadChats]); + useEffect(() => { + if (activeWorkflowId) { + _loadChats(); + } + }, [activeWorkflowId]); + + useEffect(() => { + if (editingId && renameInputRef.current) { + renameInputRef.current.focus(); + renameInputRef.current.select(); + } + }, [editingId]); + const _toggleGroup = (id: string) => { setExpandedGroups(prev => { const next = new Set(prev); @@ -83,18 +141,161 @@ const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart }); }; + const _startEditing = (chat: ChatItem) => { + if (!onRenameChat) return; + setEditingId(chat.id); + setEditName(chat.label); + }; + + const _commitRename = async (chatId: string) => { + const trimmed = editName.trim(); + setEditingId(null); + if (!trimmed || !onRenameChat) return; + await onRenameChat(chatId, trimmed); + _loadChats(); + }; + + const _handleRenameKeyDown = (e: React.KeyboardEvent, chatId: string) => { + if (e.key === 'Enter') { + e.preventDefault(); + _commitRename(chatId); + } else if (e.key === 'Escape') { + setEditingId(null); + } + }; + + const _archiveChat = useCallback(async (chatId: string) => { + try { + await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'archived' }); + _loadChats(); + } catch (err) { + console.error('Failed to archive chat:', err); + } + }, [context.instanceId, _loadChats]); + + const _restoreChat = useCallback(async (chatId: string) => { + try { + await api.patch(`/api/workspace/${context.instanceId}/workflows/${chatId}`, { status: 'active' }); + _loadChats(); + } catch (err) { + console.error('Failed to restore chat:', err); + } + }, [context.instanceId, _loadChats]); + + const _isArchived = (chat: ChatItem) => chat.status === 'archived'; + + const _applyFilter = (chats: ChatItem[]) => + chats.filter(c => filter === 'archived' ? _isArchived(c) : !_isArchived(c)); + const _filteredGroups = groups - .map(g => ({ - ...g, - chats: search - ? g.chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase())) - : g.chats, - })) + .map(g => { + let chats = _applyFilter(g.chats); + if (search) { + chats = chats.filter(c => c.label.toLowerCase().includes(search.toLowerCase())); + } + return { ...g, chats }; + }) .filter(g => g.chats.length > 0); const _allChats = _filteredGroups .flatMap(g => g.chats) - .sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || '')); + .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; + }); + + 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); + + const _renderChatItem = (chat: ChatItem, featureInstanceId: string) => { + const isActive = activeWorkflowId === chat.id; + const isEditing = editingId === chat.id; + const archived = _isArchived(chat); + + const itemClassName = [ + styles.chatItem, + isActive ? styles.chatItemActive : '', + archived ? styles.chatItemArchived : '', + ].filter(Boolean).join(' '); + + return ( +
{ + if (!isEditing) onSelectChat?.(chat.id, featureInstanceId); + }} + draggable={!!onDragStart && !isEditing} + onDragStart={(e) => { + e.dataTransfer.setData('application/chat-id', chat.id); + e.dataTransfer.setData('text/plain', chat.label); + onDragStart?.(chat.id, e); + }} + > + {isEditing ? ( + setEditName(e.target.value)} + onBlur={() => _commitRename(chat.id)} + onKeyDown={(e) => _handleRenameKeyDown(e, chat.id)} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + <> + + {_formatRelativeTime(chat.updatedAt)} + + + {chat.label} + + + {onRenameChat && ( + + )} + {archived ? ( + + ) : ( + + )} + {onDeleteChat && ( + + )} + + + )} +
+ ); + }; if (loading) return
Lade Chats...
; @@ -108,6 +309,11 @@ const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart value={search} onChange={(e) => setSearch(e.target.value)} /> + {onCreateNew && ( + + )}
+
+ + +
+ {flatMode ? (
- {_allChats.map((chat) => ( -
onSelectChat?.(chat.id, chat.featureInstanceId || context.instanceId)} - draggable={!!onDragStart} - onDragStart={(e) => { - e.dataTransfer.setData('application/chat-id', chat.id); - e.dataTransfer.setData('text/plain', chat.label); - onDragStart?.(chat.id, e); - }} - > - {chat.label} - {chat.updatedAt && ( - - {new Date(chat.updatedAt).toLocaleDateString()} - - )} -
- ))} + {_allChats.map((chat) => + _renderChatItem(chat, chat.featureInstanceId || context.instanceId), + )}
) : (
@@ -158,27 +362,21 @@ const ChatsTab: React.FC = ({ context, onSelectChat, onDragStart
{expandedGroups.has(group.featureInstanceId) && (
- {group.chats.map((chat) => ( -
onSelectChat?.(chat.id, group.featureInstanceId)} - draggable={!!onDragStart} - onDragStart={(e) => { - e.dataTransfer.setData('application/chat-id', chat.id); - e.dataTransfer.setData('text/plain', chat.label); - onDragStart?.(chat.id, e); - }} - > - {chat.label} -
- ))} + {group.chats.map((chat) => + _renderChatItem(chat, group.featureInstanceId), + )}
)} ))} )} + + {_allChats.length === 0 && ( +
+ {filter === 'archived' ? 'Keine archivierten Chats.' : 'Keine aktiven Chats.'} +
+ )} ); }; diff --git a/src/components/UnifiedDataBar/FilesTab.module.css b/src/components/UnifiedDataBar/FilesTab.module.css index a79c04c..7a48a75 100644 --- a/src/components/UnifiedDataBar/FilesTab.module.css +++ b/src/components/UnifiedDataBar/FilesTab.module.css @@ -2,6 +2,7 @@ display: flex; flex-direction: column; height: 100%; + position: relative; } .loading, diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx index 65183c9..c96ebdd 100644 --- a/src/components/UnifiedDataBar/FilesTab.tsx +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -1,26 +1,22 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import type { UdbContext } from './UnifiedDataBar'; import api from '../../api'; +import FolderTree from '../../components/FolderTree/FolderTree'; +import type { FileNode } from '../../components/FolderTree/FolderTree'; +import { useFileContext } from '../../contexts/FileContext'; import styles from './FilesTab.module.css'; interface FileEntry { id: string; fileName: string; mimeType?: string; + fileSize?: number; + folderId?: string | null; + tags?: string[]; scope: string; neutralize: boolean; - fileSize?: number; } -const _SCOPE_ICONS: Record = { - personal: '\uD83D\uDC64', - featureInstance: '\uD83D\uDC65', - mandate: '\uD83C\uDFE2', - global: '\uD83C\uDF10', -}; - -const _SCOPE_CYCLE: string[] = ['personal', 'featureInstance', 'mandate']; - interface FilesTabProps { context: UdbContext; onFileSelect?: (fileId: string) => void; @@ -29,6 +25,27 @@ interface FilesTabProps { const FilesTab: React.FC = ({ context, onFileSelect }) => { const [files, setFiles] = useState([]); const [loading, setLoading] = useState(true); + const [searchQuery, setSearchQuery] = useState(''); + const [isDragOver, setIsDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + const [selectedFolderId, setSelectedFolderId] = useState(null); + const fileInputRef = useRef(null); + + const { + folders, + refreshFolders, + handleCreateFolder, + handleRenameFolder, + handleDeleteFolder, + handleMoveFolder, + handleMoveFolders, + handleMoveFile, + handleMoveFiles: contextMoveFiles, + handleFileDelete, + handleDownloadFolder, + expandedFolderIds, + toggleFolderExpanded, + } = useFileContext(); const _loadFiles = useCallback(async () => { setLoading(true); @@ -40,9 +57,11 @@ const FilesTab: React.FC = ({ context, onFileSelect }) => { id: f.id, fileName: f.fileName || f.name || 'unknown', mimeType: f.mimeType, + fileSize: f.fileSize, + folderId: f.folderId ?? null, + tags: f.tags || [], scope: f.scope || 'personal', neutralize: f.neutralize || false, - fileSize: f.fileSize, })), ); } catch (err) { @@ -56,73 +75,245 @@ const FilesTab: React.FC = ({ context, onFileSelect }) => { _loadFiles(); }, [_loadFiles]); - const _cycleScope = async (file: FileEntry) => { - const currentIdx = _SCOPE_CYCLE.indexOf(file.scope); - const nextScope = _SCOPE_CYCLE[(currentIdx + 1) % _SCOPE_CYCLE.length]; + const _folderNodes = useMemo(() => + folders.map(f => ({ + id: f.id, + name: f.name, + parentId: f.parentId ?? null, + })), + [folders], + ); + + const _fileNodes: FileNode[] = useMemo(() => { + let result = files; + if (searchQuery.trim()) { + const q = searchQuery.toLowerCase(); + result = result.filter(f => + f.fileName.toLowerCase().includes(q) + || (f.tags || []).some((t: string) => t.toLowerCase().includes(q)), + ); + } + return result + .sort((a, b) => a.fileName.localeCompare(b.fileName)) + .map(f => ({ + id: f.id, + fileName: f.fileName, + mimeType: f.mimeType, + fileSize: f.fileSize, + folderId: f.folderId ?? null, + scope: f.scope, + neutralize: f.neutralize, + })); + }, [files, searchQuery]); + + const _refreshAll = useCallback(() => { + _loadFiles(); + refreshFolders(); + }, [_loadFiles, refreshFolders]); + + const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { + if (!context.instanceId || uploading) return; + setUploading(true); try { - await api.patch(`/api/files/${file.id}/scope`, { scope: nextScope }); - setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, scope: nextScope } : f))); + for (const file of Array.from(fileList)) { + const formData = new FormData(); + formData.append('file', file); + formData.append('featureInstanceId', context.instanceId); + await api.post('/api/files/upload', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + } + _refreshAll(); + } catch (err) { + console.error('File upload failed:', err); + } finally { + setUploading(false); + } + }, [context.instanceId, uploading, _refreshAll]); + + const _handleDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer.types.includes('Files')) { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(true); + } + }, []); + + const _handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + }, []); + + const _handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragOver(false); + if (e.dataTransfer.files.length > 0) { + _uploadFiles(e.dataTransfer.files); + } + }, [_uploadFiles]); + + const _handleFileInputChange = useCallback((e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + _uploadFiles(e.target.files); + e.target.value = ''; + } + }, [_uploadFiles]); + + const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { + await handleMoveFile(fileId, targetFolderId); + _loadFiles(); + }, [handleMoveFile, _loadFiles]); + + const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { + await contextMoveFiles(fileIds, targetFolderId); + _loadFiles(); + }, [contextMoveFiles, _loadFiles]); + + const _onDeleteFolder = useCallback(async (folderId: string) => { + await handleDeleteFolder(folderId); + if (selectedFolderId === folderId) setSelectedFolderId(null); + _loadFiles(); + }, [handleDeleteFolder, selectedFolderId, _loadFiles]); + + const _onRenameFile = useCallback(async (fileId: string, newName: string) => { + await api.put(`/api/files/${fileId}`, { fileName: newName }); + _loadFiles(); + }, [_loadFiles]); + + const _onDeleteFile = useCallback(async (fileId: string) => { + await handleFileDelete(fileId); + _loadFiles(); + }, [handleFileDelete, _loadFiles]); + + const _onDeleteFiles = useCallback(async (fileIds: string[]) => { + await api.post('/api/files/batch-delete', { fileIds }); + _loadFiles(); + }, [_loadFiles]); + + const _onDeleteFolders = useCallback(async (folderIds: string[]) => { + await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true }); + refreshFolders(); + _loadFiles(); + }, [refreshFolders, _loadFiles]); + + const _onScopeChange = useCallback(async (fileId: string, newScope: string) => { + setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, scope: newScope } : f))); + try { + await api.patch(`/api/files/${fileId}/scope`, { scope: newScope }); } catch (err) { console.error('Failed to update scope:', err); + _loadFiles(); } - }; + }, [_loadFiles]); - const _toggleNeutralize = async (file: FileEntry) => { + const _onNeutralizeToggle = useCallback(async (fileId: string, newValue: boolean) => { + setFiles(prev => prev.map(f => (f.id === fileId ? { ...f, neutralize: newValue } : f))); try { - await api.patch(`/api/files/${file.id}/neutralize`, { neutralize: !file.neutralize }); - setFiles(prev => - prev.map(f => (f.id === file.id ? { ...f, neutralize: !f.neutralize } : f)), - ); + await api.patch(`/api/files/${fileId}/neutralize`, { neutralize: newValue }); } catch (err) { console.error('Failed to toggle neutralize:', err); + _loadFiles(); } - }; + }, [_loadFiles]); if (loading) return
Lade Dateien...
; return ( -
- {files.length === 0 ? ( -
Keine Dateien vorhanden
- ) : ( -
- {files.map((file) => ( -
onFileSelect?.(file.id)} - > - {file.fileName} -
- - -
-
- ))} +
+ {isDragOver && ( +
+ Dateien hier ablegen
)} + +
+ Files +
+ + +
+
+ + + + setSearchQuery(e.target.value)} + style={{ + width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, + border: '1px solid #ddd', boxSizing: 'border-box', margin: '0 0 4px', + }} + /> + +
+ + + {_fileNodes.length === 0 && ( +
+ {searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'} +
+ )} +
+
- {'\uD83D\uDC64'} Pers\u00F6nlich + {'\uD83D\uDC64'} Persönlich {'\uD83D\uDC65'} Instanz {'\uD83C\uDFE2'} Mandant {'\uD83D\uDD12'} Neutralisiert diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx index 403dd63..f1a3fca 100644 --- a/src/components/UnifiedDataBar/SourcesTab.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -1,22 +1,1131 @@ -import React from 'react'; +/** + * SourcesTab – Full data-source management inside the Unified Data Bar. + * + * Tree structure (Browse Sources): + * UserConnection (Level 1, loaded on mount) + * └─ Service (Level 2, loaded when connection expanded) + * └─ Folder / Site / File (Level 3+, loaded when service/folder expanded) + * + * Feature Data tree: + * MandateGroup + * └─ FeatureConnection (feature instance) + * └─ FeatureTable (tables exposed by that instance) + * + * Active Sources sections show scope-cycling and neutralize-toggle buttons. + */ + +import React, { useEffect, useState, useCallback, useRef } from 'react'; import type { UdbContext } from './UnifiedDataBar'; +import api from '../../api'; +import { getPageIcon } from '../../config/pageRegistry'; import styles from './SourcesTab.module.css'; +/* ─── Types (inline, no external imports) ────────────────────────────── */ + +interface UdbDataSource { + id: string; + connectionId: string; + sourceType: string; + path: string; + label: string; + displayPath?: string; + scope: string; + neutralize: boolean; +} + +interface UdbFeatureDataSource { + id: string; + featureInstanceId: string; + featureCode: string; + tableName: string; + objectKey: string; + label: string; + scope: string; + neutralize: boolean; +} + +interface TreeNode { + key: string; + label: string; + icon: string; + type: 'connection' | 'service' | 'folder' | 'file'; + expanded: boolean; + loading: boolean; + children: TreeNode[] | null; + connectionId: string; + service?: string; + path?: string; + displayPath?: string; + authority?: string; +} + +interface FeatureConnectionNode { + featureInstanceId: string; + featureCode: string; + mandateId?: string; + label: string; + icon: string; + tableCount: number; + expanded: boolean; + loading: boolean; + tables: FeatureTableNode[] | null; +} + +interface MandateGroupNode { + mandateId: string; + mandateLabel: string; + expanded: boolean; + featureConnections: FeatureConnectionNode[]; +} + +interface FeatureTableNode { + objectKey: string; + tableName: string; + label: Record; + fields: string[]; +} + +/* ─── Props ──────────────────────────────────────────────────────────── */ + interface SourcesTabProps { context: UdbContext; - renderDataSourcePanel?: (instanceId: string) => React.ReactNode; } -const SourcesTab: React.FC = ({ context, renderDataSourcePanel }) => { - if (renderDataSourcePanel) { - return
{renderDataSourcePanel(context.instanceId)}
; +/* ─── Icons ──────────────────────────────────────────────────────────── */ + +const _AUTHORITY_ICONS: Record = { + msft: '\uD83D\uDFE6', + google: '\uD83D\uDFE9', + 'local:ftp': '\uD83D\uDD17', + 'local:jira': '\uD83D\uDD27', +}; + +const _SERVICE_ICONS: Record = { + sharepoint: '\uD83D\uDCC1', + onedrive: '\u2601\uFE0F', + outlook: '\uD83D\uDCE7', + teams: '\uD83D\uDCAC', + drive: '\uD83D\uDCC2', + gmail: '\uD83D\uDCE8', + files: '\uD83D\uDCC2', +}; + +/* ─── Source colors & icons ──────────────────────────────────────────── */ + +const _SOURCE_COLORS: Record = { + sharepointFolder: '#0078d4', + onedriveFolder: '#0078d4', + outlookFolder: '#0078d4', + googleDriveFolder: '#34a853', + gmailFolder: '#ea4335', + ftpFolder: '#795548', +}; + +function _getSourceColor(sourceType: string): string { + return _SOURCE_COLORS[sourceType] || '#1976d2'; +} + +function _getSourceIcon(sourceType: string): string { + const map: Record = { + sharepointFolder: '\uD83D\uDCC1', + onedriveFolder: '\u2601\uFE0F', + outlookFolder: '\uD83D\uDCE7', + googleDriveFolder: '\uD83D\uDCC2', + gmailFolder: '\uD83D\uDCE8', + ftpFolder: '\uD83D\uDD17', + }; + return map[sourceType] || '\uD83D\uDCC1'; +} + +/* ─── Scope / Neutralize constants ───────────────────────────────────── */ + +const _SCOPE_ORDER: string[] = ['personal', 'featureInstance', 'mandate']; + +const _SCOPE_ICONS: Record = { + personal: '\uD83D\uDC64', + featureInstance: '\uD83D\uDC65', + mandate: '\uD83C\uDFE2', + global: '\uD83C\uDF10', +}; + +const _SCOPE_LABELS: Record = { + personal: 'Personal', + featureInstance: 'Feature Instance', + mandate: 'Mandate', + global: 'Global', +}; + +function _nextScope(current: string): string { + const idx = _SCOPE_ORDER.indexOf(current); + if (idx === -1) return _SCOPE_ORDER[0]; + return _SCOPE_ORDER[(idx + 1) % _SCOPE_ORDER.length]; +} + +/* ─── Tree helpers ───────────────────────────────────────────────────── */ + +function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] { + return nodes.map(n => { + if (n.key === key) return updater(n); + if (n.children) return { ...n, children: _mapTree(n.children, key, updater) }; + return n; + }); +} + +function _mapFeatureTreeUpdate( + prev: MandateGroupNode[], + featureInstanceId: string, + updater: (n: FeatureConnectionNode) => FeatureConnectionNode, +): MandateGroupNode[] { + return prev.map(g => ({ + ...g, + featureConnections: g.featureConnections.map(n => + n.featureInstanceId === featureInstanceId ? updater(n) : n + ), + })); +} + +function _findFeatureInstanceMeta( + groups: MandateGroupNode[], + featureInstanceId: string, +): { mandateLabel: string; instanceLabel: string } | null { + for (const g of groups) { + const fc = g.featureConnections.find(f => f.featureInstanceId === featureInstanceId); + if (fc) return { mandateLabel: g.mandateLabel, instanceLabel: fc.label }; } + return null; +} + +function _personalDataSourceHoverTitle(connLabel: string, ds: UdbDataSource): string { + const pathPart = (ds.displayPath && ds.displayPath.trim()) || ds.label || ds.path || ''; + return pathPart ? `${connLabel} / ${pathPart}` : connLabel; +} + +function _featureDataSourceHoverTitle( + meta: { mandateLabel: string; instanceLabel: string } | null, + fds: UdbFeatureDataSource, +): string { + const parts: string[] = []; + if (meta) { + parts.push(meta.mandateLabel, meta.instanceLabel); + } + const labelPart = fds.label && fds.tableName && fds.label !== fds.tableName + ? `${fds.label} (${fds.tableName})` + : (fds.label || fds.tableName); + parts.push(labelPart); + if (fds.objectKey && fds.objectKey !== labelPart && !labelPart.includes(fds.objectKey)) { + parts.push(fds.objectKey); + } + return parts.join(' / '); +} + +/* ─── Data fetching (module-level) ───────────────────────────────────── */ + +async function _loadServices(instanceId: string, connectionId: string): Promise { + const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`); + const services = res.data.services || []; + return services.map((s: any) => ({ + key: `svc-${connectionId}-${s.service}`, + label: s.label || s.service, + icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2', + type: 'service' as const, + expanded: false, + loading: false, + children: null, + connectionId, + service: s.service, + path: '/', + displayPath: s.label || s.service, + })); +} + +async function _browseService( + instanceId: string, + connectionId: string, + service: string, + path: string, + parentDisplayPath: string | undefined, +): Promise { + const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, { + params: { service, path }, + }); + const items = res.data.items || []; + return items.map((entry: any, idx: number) => { + const seg = entry.name || ''; + const displayPath = parentDisplayPath + ? `${parentDisplayPath} / ${seg}` + : seg; + return { + key: `item-${connectionId}-${service}-${entry.path || idx}`, + label: entry.name, + icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name), + type: entry.isFolder ? 'folder' as const : 'file' as const, + expanded: false, + loading: false, + children: entry.isFolder ? null : [], + connectionId, + service, + path: entry.path, + displayPath, + }; + }); +} + +function _fileIcon(name: string): string { + const ext = name.split('.').pop()?.toLowerCase() || ''; + const map: Record = { + pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD', + xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA', + ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8', + txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB', + png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F', + zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', + mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', + mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', + }; + return map[ext] || '\uD83D\uDCC4'; +} + +/* ─── Spinner (inline) ───────────────────────────────────────────────── */ + +function _Spinner(): React.ReactElement { + return ( + + ); +} + +/* ─── Component ──────────────────────────────────────────────────────── */ + +const SourcesTab: React.FC = ({ context }) => { + const instanceId = context.instanceId; + + /* ── Active sources (fetched internally) ── */ + const [dataSources, setDataSources] = useState([]); + const [featureDataSources, setFeatureDataSources] = useState([]); + + /* ── Browse tree state ── */ + const [tree, setTree] = useState([]); + const [loadingRoot, setLoadingRoot] = useState(false); + const [addingPath, setAddingPath] = useState(null); + + /* ── Feature tree state ── */ + const [featureTree, setFeatureTree] = useState([]); + const [loadingFeatures, setLoadingFeatures] = useState(false); + const [addingFeatureKey, setAddingFeatureKey] = useState(null); + + const mountedRef = useRef(true); + useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); + + /* ── Fetch active personal data sources ── */ + const _fetchDataSources = useCallback(() => { + if (!instanceId) return; + api.get(`/api/workspace/${instanceId}/datasources`) + .then(res => { + if (!mountedRef.current) return; + const list: UdbDataSource[] = (res.data.dataSources || res.data || []).map((d: any) => ({ + id: d.id, + connectionId: d.connectionId, + sourceType: d.sourceType, + path: d.path, + label: d.label, + displayPath: d.displayPath, + scope: d.scope || 'personal', + neutralize: d.neutralize ?? false, + })); + setDataSources(list); + }) + .catch(() => { if (mountedRef.current) setDataSources([]); }); + }, [instanceId]); + + /* ── Fetch active feature data sources ── */ + const _fetchFeatureDataSources = useCallback(() => { + if (!instanceId) return; + api.get(`/api/workspace/${instanceId}/feature-datasources`) + .then(res => { + if (!mountedRef.current) return; + const list: UdbFeatureDataSource[] = (res.data.featureDataSources || res.data || []).map((d: any) => ({ + id: d.id, + featureInstanceId: d.featureInstanceId, + featureCode: d.featureCode, + tableName: d.tableName, + objectKey: d.objectKey, + label: d.label, + scope: d.scope || 'personal', + neutralize: d.neutralize ?? false, + })); + setFeatureDataSources(list); + }) + .catch(() => { if (mountedRef.current) setFeatureDataSources([]); }); + }, [instanceId]); + + useEffect(() => { _fetchDataSources(); }, [_fetchDataSources]); + useEffect(() => { _fetchFeatureDataSources(); }, [_fetchFeatureDataSources]); + + /* ── Load Level 1: UserConnections ── */ + const _loadConnections = useCallback(() => { + if (!instanceId) return; + setLoadingRoot(true); + api.get(`/api/workspace/${instanceId}/connections`) + .then(res => { + if (!mountedRef.current) return; + const conns = res.data.connections || []; + const nodes: TreeNode[] = conns + .filter((c: any) => c.status === 'active') + .map((c: any) => ({ + key: `conn-${c.id}`, + label: c.externalEmail || c.externalUsername || c.authority, + icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17', + type: 'connection' as const, + expanded: false, + loading: false, + children: null, + connectionId: c.id, + authority: c.authority, + })); + setTree(nodes); + }) + .catch(() => { if (mountedRef.current) setTree([]); }) + .finally(() => { if (mountedRef.current) setLoadingRoot(false); }); + }, [instanceId]); + + useEffect(() => { _loadConnections(); }, [_loadConnections]); + + /* ── Generic tree update helper ── */ + const _updateNode = useCallback((key: string, updater: (node: TreeNode) => TreeNode) => { + setTree(prev => _mapTree(prev, key, updater)); + }, []); + + /* ── Toggle expand/collapse ── */ + const _toggleNode = useCallback(async (node: TreeNode) => { + if (node.expanded) { + _updateNode(node.key, n => ({ ...n, expanded: false })); + return; + } + + if (node.children !== null) { + _updateNode(node.key, n => ({ ...n, expanded: true })); + return; + } + + _updateNode(node.key, n => ({ ...n, loading: true, expanded: true })); + + try { + let children: TreeNode[] = []; + + if (node.type === 'connection') { + children = await _loadServices(instanceId, node.connectionId); + } else if (node.type === 'service' || node.type === 'folder') { + children = await _browseService( + instanceId, + node.connectionId, + node.service!, + node.path || '/', + node.displayPath || node.label, + ); + } + + if (mountedRef.current) { + _updateNode(node.key, n => ({ ...n, loading: false, children })); + } + } catch { + if (mountedRef.current) { + _updateNode(node.key, n => ({ ...n, loading: false, children: [] })); + } + } + }, [instanceId, _updateNode]); + + /* ── Add as DataSource ── */ + const _addAsDataSource = useCallback(async (node: TreeNode) => { + if (!node.service || !node.connectionId) return; + setAddingPath(node.key); + try { + const sourceTypeMap: Record = { + sharepoint: 'sharepointFolder', + onedrive: 'onedriveFolder', + outlook: 'outlookFolder', + drive: 'googleDriveFolder', + gmail: 'gmailFolder', + files: 'ftpFolder', + }; + await api.post(`/api/workspace/${instanceId}/datasources`, { + connectionId: node.connectionId, + sourceType: sourceTypeMap[node.service] || node.service, + path: node.path || '/', + label: node.label, + displayPath: node.displayPath || node.label, + }); + _fetchDataSources(); + } catch (err) { + console.error('Failed to add data source:', err); + } finally { + if (mountedRef.current) setAddingPath(null); + } + }, [instanceId, _fetchDataSources]); + + /* ── Remove DataSource ── */ + const _removeDatasource = useCallback(async (dsId: string) => { + try { + await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`); + _fetchDataSources(); + } catch (err) { + console.error('Failed to remove data source:', err); + } + }, [instanceId, _fetchDataSources]); + + /* ── Check if a path is already added ── */ + const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => { + return dataSources.some(ds => + ds.connectionId === connectionId && ds.path === (path || '/'), + ); + }, [dataSources]); + + /* ── Scope change (personal data source, optimistic) ── */ + const _cyclePersonalScope = useCallback(async (ds: UdbDataSource) => { + const newScope = _nextScope(ds.scope); + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: newScope } : d)); + try { + await api.patch(`/api/datasources/${ds.id}/scope`, { scope: newScope }); + } catch { + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, scope: ds.scope } : d)); + } + }, []); + + /* ── Neutralize toggle (personal data source, optimistic) ── */ + const _togglePersonalNeutralize = useCallback(async (ds: UdbDataSource) => { + const newValue = !ds.neutralize; + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: newValue } : d)); + try { + await api.patch(`/api/datasources/${ds.id}/neutralize`, { neutralize: newValue }); + } catch { + setDataSources(prev => prev.map(d => d.id === ds.id ? { ...d, neutralize: ds.neutralize } : d)); + } + }, []); + + /* ── Scope change (feature data source, optimistic) ── */ + const _cycleFeatureScope = useCallback(async (fds: UdbFeatureDataSource) => { + const newScope = _nextScope(fds.scope); + setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: newScope } : d)); + try { + await api.patch(`/api/datasources/${fds.id}/scope`, { scope: newScope }); + } catch { + setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, scope: fds.scope } : d)); + } + }, []); + + /* ── Neutralize toggle (feature data source, optimistic) ── */ + const _toggleFeatureNeutralize = useCallback(async (fds: UdbFeatureDataSource) => { + const newValue = !fds.neutralize; + setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: newValue } : d)); + try { + await api.patch(`/api/datasources/${fds.id}/neutralize`, { neutralize: newValue }); + } catch { + setFeatureDataSources(prev => prev.map(d => d.id === fds.id ? { ...d, neutralize: fds.neutralize } : d)); + } + }, []); + + /* ── Feature Connections: Load Level 1 ── */ + const _loadFeatureConnections = useCallback(() => { + if (!instanceId) return; + setLoadingFeatures(true); + api.get(`/api/workspace/${instanceId}/feature-connections`) + .then(res => { + if (!mountedRef.current) return; + const groups = res.data.featureConnectionsByMandate || []; + setFeatureTree(groups.map((g: any) => ({ + mandateId: g.mandateId, + mandateLabel: g.mandateLabel || g.mandateId, + expanded: true, + featureConnections: (g.featureConnections || []).map((c: any) => ({ + featureInstanceId: c.featureInstanceId, + featureCode: c.featureCode, + mandateId: c.mandateId, + label: c.label, + icon: c.icon || '\uD83D\uDDC3\uFE0F', + tableCount: c.tableCount || 0, + expanded: false, + loading: false, + tables: null, + })), + }))); + }) + .catch(() => { if (mountedRef.current) setFeatureTree([]); }) + .finally(() => { if (mountedRef.current) setLoadingFeatures(false); }); + }, [instanceId]); + + useEffect(() => { _loadFeatureConnections(); }, [_loadFeatureConnections]); + + /* ── Feature Connections: Toggle mandate group ── */ + const _toggleMandateGroup = useCallback((mandateId: string) => { + setFeatureTree(prev => prev.map(g => + g.mandateId === mandateId ? { ...g, expanded: !g.expanded } : g + )); + }, []); + + /* ── Feature Connections: Toggle expand ── */ + const _toggleFeatureNode = useCallback(async (node: FeatureConnectionNode) => { + if (node.expanded) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: false }))); + return; + } + + if (node.tables !== null) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: true }))); + return; + } + + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ + ...n, loading: true, expanded: true, + }))); + + try { + const res = await api.get(`/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/tables`); + const tables: FeatureTableNode[] = (res.data.tables || []).map((t: any) => ({ + objectKey: t.objectKey, + tableName: t.tableName, + label: t.label || {}, + fields: t.fields || [], + })); + if (mountedRef.current) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ + ...n, loading: false, tables, + }))); + } + } catch { + if (mountedRef.current) { + setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ + ...n, loading: false, tables: [], + }))); + } + } + }, [instanceId]); + + /* ── Feature: Add table as FeatureDataSource ── */ + const _addFeatureTable = useCallback(async (node: FeatureConnectionNode, table: FeatureTableNode) => { + const key = `${node.featureInstanceId}-${table.tableName}`; + setAddingFeatureKey(key); + try { + await api.post(`/api/workspace/${instanceId}/feature-datasources`, { + featureInstanceId: node.featureInstanceId, + featureCode: node.featureCode, + tableName: table.tableName, + objectKey: table.objectKey, + label: table.label?.en || table.label?.de || table.tableName, + }); + _fetchFeatureDataSources(); + } catch (err) { + console.error('Failed to add feature data source:', err); + } finally { + if (mountedRef.current) setAddingFeatureKey(null); + } + }, [instanceId, _fetchFeatureDataSources]); + + /* ── Feature: Remove FeatureDataSource ── */ + const _removeFeatureDataSource = useCallback(async (fdsId: string) => { + try { + await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`); + _fetchFeatureDataSources(); + } catch (err) { + console.error('Failed to remove feature data source:', err); + } + }, [instanceId, _fetchFeatureDataSources]); + + /* ── Feature: check if table already added ── */ + const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => { + return featureDataSources.some(fds => + fds.featureInstanceId === featureInstanceId && fds.tableName === tableName, + ); + }, [featureDataSources]); + + /* ── Render ── */ return ( -
-
- Datenquellen werden \u00FCber den Workspace verwaltet. +
+ {/* ── Active Personal Sources ── */} + {dataSources.length > 0 && ( +
+
+ Active Personal Sources +
+ {dataSources.map(ds => { + const connColor = _getSourceColor(ds.sourceType); + const connNode = tree.find(n => n.connectionId === ds.connectionId); + const connLabel = connNode?.label || ds.connectionId; + const folder = ds.label || ds.path || ds.id; + return ( +
+ {_getSourceIcon(ds.sourceType)} + + {connLabel} – {folder} + + + + +
+ ); + })} +
+
+ )} + + {/* ── Browse Sources header ── */} +
+ + Browse Sources + +
+ + {/* ── Browse Sources tree ── */} + {loadingRoot && tree.length === 0 && ( +
+ Loading connections... +
+ )} + + {!loadingRoot && tree.length === 0 && ( +
+ No active connections found. +
+ )} + + {tree.map(node => ( + <_TreeNodeView + key={node.key} + node={node} + depth={0} + onToggle={_toggleNode} + onAdd={_addAsDataSource} + isAdded={_isAdded} + addingPath={addingPath} + /> + ))} + + {/* ── Divider ── */} +
+ + {/* ── Active Feature Sources ── */} + {featureDataSources.length > 0 && ( +
+
+ Active Feature Sources +
+ {featureDataSources.map(fds => { + const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); + const fdsConnLabel = meta?.instanceLabel || fds.tableName; + return ( +
+ + {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'} + + + {fdsConnLabel} – {fds.tableName} + + + + +
+ ); + })} +
+
+ )} + + {/* ── Feature Data header ── */} +
+ + Feature Data + + +
+ + {/* ── Feature Data tree ── */} + {loadingFeatures && featureTree.length === 0 && ( +
+ Loading feature instances... +
+ )} + + {!loadingFeatures && featureTree.length === 0 && ( +
+ No feature instances found. +
+ )} + + {featureTree.map(g => ( + <_MandateGroupView + key={g.mandateId} + group={g} + onToggleGroup={_toggleMandateGroup} + onToggleFeature={_toggleFeatureNode} + onAddTable={_addFeatureTable} + isTableAdded={_isFeatureTableAdded} + addingKey={addingFeatureKey} + /> + ))} +
+ ); +}; + +/* ─── TreeNodeView (recursive) ───────────────────────────────────────── */ + +interface _TreeNodeViewProps { + node: TreeNode; + depth: number; + onToggle: (node: TreeNode) => void; + onAdd: (node: TreeNode) => void; + isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean; + addingPath: string | null; +} + +const _TreeNodeView: React.FC<_TreeNodeViewProps> = ({ + node, depth, onToggle, onAdd, isAdded, addingPath, +}) => { + const [hovered, setHovered] = useState(false); + const hasChildren = node.type !== 'file'; + const chevron = hasChildren + ? (node.expanded ? '\u25BE' : '\u25B8') + : '\u00A0\u00A0'; + const canAdd = node.type === 'folder' || node.type === 'service'; + const alreadyAdded = canAdd && isAdded(node.connectionId, node.service, node.path); + const isAdding = addingPath === node.key; + + return ( +
+
{ if (hasChildren) onToggle(node); }} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 4, + paddingLeft: depth * 16 + 4, + paddingRight: 4, + paddingTop: 3, + paddingBottom: 3, + cursor: hasChildren ? 'pointer' : 'default', + borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', + userSelect: 'none', + }} + > + + {node.loading ? _Spinner() : chevron} + + {node.icon} + + {node.label} + + {canAdd && hovered && !alreadyAdded && ( + + )} + {canAdd && alreadyAdded && ( + + {'\u2713'} + + )} +
+ + {node.expanded && node.children && node.children.length > 0 && ( +
+ {node.children.map(child => ( + <_TreeNodeView + key={child.key} + node={child} + depth={depth + 1} + onToggle={onToggle} + onAdd={onAdd} + isAdded={isAdded} + addingPath={addingPath} + /> + ))} +
+ )} + + {node.expanded && node.children && node.children.length === 0 && !node.loading && ( +
+ (empty) +
+ )} +
+ ); +}; + +/* ─── MandateGroupView (mandate + feature instances) ─────────────────── */ + +interface _MandateGroupViewProps { + group: MandateGroupNode; + onToggleGroup: (mandateId: string) => void; + onToggleFeature: (node: FeatureConnectionNode) => void; + onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; + isTableAdded: (featureInstanceId: string, tableName: string) => boolean; + addingKey: string | null; +} + +const _MandateGroupView: React.FC<_MandateGroupViewProps> = ({ + group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey, +}) => { + const [hovered, setHovered] = useState(false); + const chevron = group.expanded ? '\u25BE' : '\u25B8'; + + return ( +
+
onToggleGroup(group.mandateId)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + cursor: 'pointer', borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', userSelect: 'none', + }} + > + + {chevron} + + + {group.mandateLabel} + +
+ + {group.expanded && ( +
+ {group.featureConnections.map(fNode => ( + <_FeatureNodeView + key={fNode.featureInstanceId} + node={fNode} + onToggle={onToggleFeature} + onAddTable={onAddTable} + isTableAdded={isTableAdded} + addingKey={addingKey} + /> + ))} +
+ )} +
+ ); +}; + +/* ─── FeatureNodeView (feature instance + tables) ────────────────────── */ + +interface _FeatureNodeViewProps { + node: FeatureConnectionNode; + onToggle: (node: FeatureConnectionNode) => void; + onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; + isTableAdded: (featureInstanceId: string, tableName: string) => boolean; + addingKey: string | null; +} + +const _FeatureNodeView: React.FC<_FeatureNodeViewProps> = ({ + node, onToggle, onAddTable, isTableAdded, addingKey, +}) => { + const [hovered, setHovered] = useState(false); + const chevron = node.expanded ? '\u25BE' : '\u25B8'; + + return ( +
+
onToggle(node)} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + cursor: 'pointer', borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', userSelect: 'none', + }} + > + + {node.loading ? _Spinner() : chevron} + + + {getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'} + + + {node.label} + + + {node.tableCount} tables + +
+ + {node.expanded && node.tables && node.tables.length > 0 && ( +
+ {node.tables.map(table => ( + <_FeatureTableRow + key={table.objectKey} + featureNode={node} + table={table} + onAdd={onAddTable} + isAdded={isTableAdded(node.featureInstanceId, table.tableName)} + isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`} + /> + ))} +
+ )} + + {node.expanded && node.tables && node.tables.length === 0 && !node.loading && ( +
+ (no tables) +
+ )} +
+ ); +}; + +/* ─── FeatureTableRow ────────────────────────────────────────────────── */ + +interface _FeatureTableRowProps { + featureNode: FeatureConnectionNode; + table: FeatureTableNode; + onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; + isAdded: boolean; + isAdding: boolean; +} + +const _FeatureTableRow: React.FC<_FeatureTableRowProps> = ({ + featureNode, table, onAdd, isAdded, isAdding, +}) => { + const [hovered, setHovered] = useState(false); + const tableLabel = table.label?.en || table.label?.de || table.tableName; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 4, + paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3, + borderRadius: 3, + background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', + transition: 'background 0.1s', userSelect: 'none', + }} + title={`${table.tableName}: ${table.fields.join(', ')}`} + > + {'\uD83D\uDCC1'} + + {tableLabel} + + {hovered && !isAdded && ( + + )} + {isAdded && ( + + {'\u2713'} + + )}
); }; diff --git a/src/components/UnifiedDataBar/UnifiedDataBar.tsx b/src/components/UnifiedDataBar/UnifiedDataBar.tsx index 00ae85f..8a6ddc9 100644 --- a/src/components/UnifiedDataBar/UnifiedDataBar.tsx +++ b/src/components/UnifiedDataBar/UnifiedDataBar.tsx @@ -1,4 +1,7 @@ import React, { useState } from 'react'; +import ChatsTab from './ChatsTab'; +import FilesTab from './FilesTab'; +import SourcesTab from './SourcesTab'; import styles from './UnifiedDataBar.module.css'; export type UdbTab = 'chats' | 'files' | 'sources'; @@ -14,10 +17,14 @@ interface UnifiedDataBarProps { context: UdbContext; activeTab?: UdbTab; onTabChange?: (tab: UdbTab) => void; - renderChats?: (context: UdbContext) => React.ReactNode; - renderFiles?: (context: UdbContext) => React.ReactNode; - renderSources?: (context: UdbContext) => React.ReactNode; + hideTabs?: UdbTab[]; + onSelectChat?: (chatId: string, featureInstanceId: string) => void; + activeWorkflowId?: string; + onCreateNewChat?: () => void; + onRenameChat?: (chatId: string, newName: string) => void; + onDeleteChat?: (chatId: string) => void; onChatDragStart?: (chatId: string, event: React.DragEvent) => void; + onFileSelect?: (fileId: string) => void; className?: string; } @@ -31,12 +38,20 @@ const UnifiedDataBar: React.FC = ({ context, activeTab: controlledTab, onTabChange, - renderChats, - renderFiles, - renderSources, + hideTabs, + onSelectChat, + activeWorkflowId, + onCreateNewChat, + onRenameChat, + onDeleteChat, + onChatDragStart, + onFileSelect, className, }) => { - const [internalTab, setInternalTab] = useState('chats'); + const visibleTabs = (['chats', 'files', 'sources'] as UdbTab[]).filter( + t => !hideTabs?.includes(t), + ); + const [internalTab, setInternalTab] = useState(controlledTab ?? visibleTabs[0] ?? 'chats'); const currentTab = controlledTab ?? internalTab; const _handleTabChange = (tab: UdbTab) => { @@ -47,7 +62,7 @@ const UnifiedDataBar: React.FC = ({ return (
- {(['chats', 'files', 'sources'] as UdbTab[]).map((tab) => ( + {visibleTabs.map((tab) => (
- {currentTab === 'chats' && renderChats?.(context)} - {currentTab === 'files' && renderFiles?.(context)} - {currentTab === 'sources' && renderSources?.(context)} + {currentTab === 'chats' && !hideTabs?.includes('chats') && ( + + )} + {currentTab === 'files' && !hideTabs?.includes('files') && ( + + )} + {currentTab === 'sources' && !hideTabs?.includes('sources') && ( + + )}
); diff --git a/src/components/UnifiedDataBar/index.ts b/src/components/UnifiedDataBar/index.ts index bb63a3a..83b7dfc 100644 --- a/src/components/UnifiedDataBar/index.ts +++ b/src/components/UnifiedDataBar/index.ts @@ -1,6 +1,3 @@ export { default as UnifiedDataBar } from './UnifiedDataBar'; export type { UdbContext, UdbTab } from './UnifiedDataBar'; -export { default as ChatsTab } from './ChatsTab'; -export { default as FilesTab } from './FilesTab'; -export { default as SourcesTab } from './SourcesTab'; export { useUdlContext } from './useUdlContext'; diff --git a/src/hooks/useNavigation.ts b/src/hooks/useNavigation.ts index c72d8da..2eaf47b 100644 --- a/src/hooks/useNavigation.ts +++ b/src/hooks/useNavigation.ts @@ -66,6 +66,7 @@ export interface FeatureInstance { uiLabel: string; order: number; views: FeatureView[]; + isAdmin?: boolean; } /** Feature within a mandate */ diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index 7ff80d0..d3ad680 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -7,11 +7,12 @@ */ import React from 'react'; -import { Link, Navigate } from 'react-router-dom'; +import { Link } from 'react-router-dom'; import useNavigation from '../hooks/useNavigation'; import type { NavigationMandate, MandateFeature, FeatureInstance as NavFeatureInstance } from '../hooks/useNavigation'; import { getPageIcon } from '../config/pageRegistry'; import { FaArrowRight, FaBuilding } from 'react-icons/fa'; +import OnboardingAssistant from '../components/OnboardingAssistant'; import styles from './Dashboard.module.css'; // ============================================================================= @@ -75,19 +76,19 @@ export const DashboardPage: React.FC = () => { ); } - if (totalInstances === 0) { - return ; - } - return (

Übersicht

-

- Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}. -

+ {totalInstances > 0 && ( +

+ Du hast Zugriff auf {totalInstances} Feature-Instanz{totalInstances !== 1 ? 'en' : ''} in {totalMandates} Mandant{totalMandates !== 1 ? 'en' : ''}. +

+ )}
+ +
{mandates .filter(mandate => mandate.features.some(f => f.instances.length > 0)) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 8a8ae74..5b4cf08 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -17,12 +17,13 @@ import styles from './Settings.module.css'; // TYPES // ============================================================================= -type SettingsTab = 'profile' | 'appearance' | 'voice' | 'privacy'; +type SettingsTab = 'profile' | 'appearance' | 'voice' | 'neutralization' | 'privacy'; const _TABS: { key: SettingsTab; label: string }[] = [ { key: 'profile', label: 'Profil' }, { key: 'appearance', label: 'Darstellung' }, { key: 'voice', label: 'Stimme & Sprache' }, + { key: 'neutralization', label: 'Datenneutralisierung' }, { key: 'privacy', label: 'Datenschutz' }, ]; @@ -296,6 +297,116 @@ const VoiceSettingsTab: React.FC = () => { ); }; +// ============================================================================= +// NEUTRALIZATION MAPPINGS TAB +// ============================================================================= + +interface NeutralizationMapping { + id: string; + originalText: string; + patternType: string; + fileId?: string; + featureInstanceId?: string; +} + +const NeutralizationMappingsTab: React.FC = () => { + const { request } = useApiRequest(); + const [mappings, setMappings] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const _load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const result: any = await request({ url: '/api/local/neutralization-mappings', method: 'get' }); + const items = (result?.mappings || []).map((m: any) => ({ + id: m.id, + originalText: m.originalText || '', + patternType: m.patternType || '', + fileId: m.fileId, + featureInstanceId: m.featureInstanceId, + })); + setMappings(items); + } catch (err: any) { + setError(err.message || 'Fehler beim Laden'); + } finally { + setLoading(false); + } + }, [request]); + + useEffect(() => { _load(); }, [_load]); + + const _handleDelete = useCallback(async (id: string) => { + try { + await request({ url: `/api/local/neutralization-mappings/${id}`, method: 'delete' }); + setMappings(prev => prev.filter(m => m.id !== id)); + } catch (err: any) { + setError(err.message || 'Fehler beim Loeschen'); + } + }, [request]); + + const _maskText = (text: string) => { + if (text.length <= 4) return '****'; + return text.slice(0, 2) + '*'.repeat(Math.min(text.length - 4, 20)) + text.slice(-2); + }; + + if (loading) return
Mappings werden geladen...
; + + return ( + <> + {error &&
{error}
} + +
+

Platzhalter-Mappings

+

+ Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt. + Hier sehen Sie Ihre gespeicherten Mappings und koennen sie loeschen. +

+ + {mappings.length === 0 ? ( +
+ Keine Neutralisierungs-Mappings vorhanden. +
+ ) : ( + + + + + + + + + + {mappings.map(m => ( + + + + + + + ))} + +
Platzhalter-IDOriginaltextTyp +
{m.id.slice(0, 12)}...{_maskText(m.originalText)} + + {m.patternType} + + + +
+ )} +
+ + ); +}; + // ============================================================================= // SETTINGS PAGE // ============================================================================= @@ -421,6 +532,8 @@ export const SettingsPage: React.FC = () => { {activeTab === 'voice' && } + {activeTab === 'neutralization' && } + {activeTab === 'privacy' && (

Datenschutz

diff --git a/src/pages/Store.module.css b/src/pages/Store.module.css index a6e1897..d12f7e4 100644 --- a/src/pages/Store.module.css +++ b/src/pages/Store.module.css @@ -211,6 +211,9 @@ /* Actions */ .cardActions { + display: flex; + flex-direction: column; + gap: 0.5rem; padding-top: 0.5rem; border-top: 1px solid var(--border-color, #e0e0e0); } diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx index c4f901b..1162d26 100644 --- a/src/pages/Store.tsx +++ b/src/pages/Store.tsx @@ -1,12 +1,10 @@ /** - * Store Page - * - * Feature Store where users can self-activate features in the root mandate. - * Uses the Shared Instance Pattern -- each feature has one shared instance, - * and users get their own FeatureAccess + user-role upon activation. + * Feature Store -- Users activate feature instances in their own mandates. + * Uses the Own Instance Pattern -- each activation creates a dedicated FeatureInstance + * in the selected mandate. Explicit mandate selection required. */ -import React, { useState } from 'react'; +import React from 'react'; import { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa'; import { useLanguage } from '../providers/language/LanguageContext'; import { useStore } from '../hooks/useStore'; @@ -76,22 +74,10 @@ const FeatureCard: React.FC = ({ onActivate, onDeactivate, }) => { - const [selectedMandateId, setSelectedMandateId] = useState(''); const isProcessing = actionLoading === feature.featureCode; const icon = FEATURE_ICONS[feature.featureCode]; const activeInstances = feature.instances.filter(inst => inst.isActive); const hasActive = activeInstances.length > 0; - const needsMandateSelection = mandates.length > 1; - - const _handleActivate = () => { - if (needsMandateSelection) { - onActivate(feature.featureCode, selectedMandateId || undefined); - } else if (mandates.length === 1) { - onActivate(feature.featureCode, mandates[0].id); - } else { - onActivate(feature.featureCode); - } - }; return (
@@ -142,43 +128,22 @@ const FeatureCard: React.FC = ({ )}
- {feature.canActivate && ( - <> - {mandates.length === 0 && ( -

- {language === 'de' - ? 'Ein persoenliches Konto wird automatisch erstellt.' + {feature.canActivate && mandates.map((m) => ( + - - )} + ? `Activer pour ${m.label || m.name}` + : `Activate for ${m.label || m.name}`)} + + ))}

); diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 5e0b662..0a027b8 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -228,7 +228,7 @@ export const ConnectionsPage: React.FC = () => {

Verbindungen

-

OAuth-Verbindungen verwalten

+

Persönliche Datenanbindungen verwalten

@@ -523,6 +523,8 @@ export const CommcoachDossierView: React.FC = () => { )} )} + + {/* #region agent log */}
))}
+ {(msg as any).neutralizationExcluded?.length > 0 && ( +
+
+ Nicht gesendet (Neutralisierung fehlgeschlagen): +
+ {(msg as any).neutralizationExcluded.map((docName: string, i: number) => ( +
+ {docName} +
+ ))} +
+ )} )}
diff --git a/src/pages/views/workspace/ConversationList.tsx b/src/pages/views/workspace/ConversationList.tsx deleted file mode 100644 index c70f5f1..0000000 --- a/src/pages/views/workspace/ConversationList.tsx +++ /dev/null @@ -1,438 +0,0 @@ -/** - * ConversationList -- Shows all workspace workflows/conversations. - * - * Features: filter, rename (double-click), delete, archive, create new, - * pagination (20 per page), last-activity display. - */ - -import React, { useState, useEffect, useCallback, useRef } from 'react'; -import api from '../../../api'; - -const _PAGE_SIZE = 20; - -interface Conversation { - id: string; - name: string; - status: string; - startedAt?: number; - lastActivity?: number; -} - -interface ConversationListProps { - instanceId: string; - activeWorkflowId: string | null; - onSelect: (workflowId: string) => void; - onCreateNew?: () => void; - refreshTrigger?: number; -} - -export const ConversationList: React.FC = ({ - instanceId, - activeWorkflowId, - onSelect, - onCreateNew, - refreshTrigger, -}) => { - const [conversations, setConversations] = useState([]); - const [loading, setLoading] = useState(false); - const [editingId, setEditingId] = useState(null); - const [editName, setEditName] = useState(''); - const [filterQuery, setFilterQuery] = useState(''); - const [page, setPage] = useState(0); - const [confirmDeleteId, setConfirmDeleteId] = useState(null); - const [viewMode, setViewMode] = useState<'active' | 'archived'>('active'); - const inputRef = useRef(null); - - const _loadConversations = useCallback(() => { - if (!instanceId) return; - setLoading(true); - api.get(`/api/workspace/${instanceId}/workflows`, { params: { includeArchived: true } }) - .then(res => { - const items = (res.data.workflows || res.data || []) - .map((w: any) => ({ - id: w.id, - name: w.name || w.label || 'Untitled', - status: w.status || 'unknown', - startedAt: w.startedAt || w.createdAt, - lastActivity: w.lastActivity || w.updatedAt || w.startedAt, - })) - .sort((a: Conversation, b: Conversation) => - (b.lastActivity || 0) - (a.lastActivity || 0), - ); - setConversations(items); - }) - .catch(() => setConversations([])) - .finally(() => setLoading(false)); - }, [instanceId]); - - useEffect(() => { - _loadConversations(); - }, [_loadConversations]); - - useEffect(() => { - if (refreshTrigger) _loadConversations(); - }, [refreshTrigger, _loadConversations]); - - useEffect(() => { - if (activeWorkflowId && !conversations.find(c => c.id === activeWorkflowId)) { - _loadConversations(); - } - }, [activeWorkflowId, conversations, _loadConversations]); - - useEffect(() => { - if (editingId && inputRef.current) { - inputRef.current.focus(); - inputRef.current.select(); - } - }, [editingId]); - - const _formatTime = (ts?: number): string => { - if (!ts) return ''; - const d = new Date(ts * 1000); - const now = new Date(); - const diffMs = now.getTime() - d.getTime(); - const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); - if (diffDays === 0) { - return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - } - if (diffDays === 1) return 'Gestern'; - if (diffDays < 7) return `vor ${diffDays}d`; - return d.toLocaleDateString([], { month: 'short', day: 'numeric' }); - }; - - const _formatDate = (ts?: number): string => { - if (!ts) return ''; - const d = new Date(ts * 1000); - return d.toLocaleDateString([], { day: '2-digit', month: '2-digit', year: 'numeric' }) - + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - }; - - const _startEditing = (conv: Conversation) => { - setEditingId(conv.id); - setEditName(conv.name); - }; - - const _commitRename = (convId: string) => { - const trimmed = editName.trim(); - if (!trimmed) { - setEditingId(null); - return; - } - setConversations(prev => - prev.map(c => c.id === convId ? { ...c, name: trimmed } : c), - ); - setEditingId(null); - api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { name: trimmed }) - .catch(() => _loadConversations()); - }; - - const _handleKeyDown = (e: React.KeyboardEvent, convId: string) => { - if (e.key === 'Enter') { - e.preventDefault(); - _commitRename(convId); - } else if (e.key === 'Escape') { - setEditingId(null); - } - }; - - const _handleDelete = (convId: string) => { - setConversations(prev => prev.filter(c => c.id !== convId)); - if (activeWorkflowId === convId) onSelect(''); - api.delete(`/api/workspace/${instanceId}/workflows/${convId}`) - .catch(() => _loadConversations()); - }; - - const _handleArchive = (convId: string) => { - setConversations(prev => prev.map(c => - c.id === convId ? { ...c, status: 'archived' } : c, - )); - api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'archived' }) - .catch(() => _loadConversations()); - }; - - const _handleReactivate = (convId: string) => { - setConversations(prev => prev.map(c => - c.id === convId ? { ...c, status: 'active' } : c, - )); - api.patch(`/api/workspace/${instanceId}/workflows/${convId}`, { status: 'active' }) - .catch(() => _loadConversations()); - }; - - const _handleCreateNew = () => { - if (onCreateNew) onCreateNew(); - }; - - const _filtered = (items: Conversation[], query: string): Conversation[] => { - if (!query.trim()) return items; - const q = query.toLowerCase(); - return items.filter(c => - c.name.toLowerCase().includes(q) || c.status.toLowerCase().includes(q), - ); - }; - - const _byStatus = viewMode === 'archived' - ? conversations.filter(c => c.status === 'archived') - : conversations.filter(c => c.status !== 'archived'); - const filtered = _filtered(_byStatus, filterQuery); - const totalPages = Math.ceil(filtered.length / _PAGE_SIZE); - const paginated = filtered.slice(page * _PAGE_SIZE, (page + 1) * _PAGE_SIZE); - - const _archivedCount = conversations.filter(c => c.status === 'archived').length; - const _activeCount = conversations.filter(c => c.status !== 'archived').length; - - useEffect(() => { setPage(0); }, [filterQuery, viewMode]); - - return ( -
- {/* Header */} -
- Conversations -
- - -
-
- - {/* View mode toggle */} -
- - -
- - {/* Filter */} - {filtered.length > 3 && ( - setFilterQuery(e.target.value)} - style={{ - width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, - border: '1px solid #ddd', marginBottom: 8, boxSizing: 'border-box', - }} - /> - )} - - {/* Empty state */} - {filtered.length === 0 && !loading && ( -
- {viewMode === 'archived' - ? 'Keine archivierten Chats.' - : 'Noch keine Chats. Sende eine Nachricht oder klicke "+".'} -
- )} - - {/* List */} - {paginated.map(conv => { - const isActive = conv.id === activeWorkflowId; - const isEditing = editingId === conv.id; - return ( -
{ if (!isEditing) onSelect(conv.id); }} - style={{ - padding: '8px 10px', - marginBottom: 4, - borderRadius: 6, - cursor: isEditing ? 'default' : 'pointer', - background: isActive ? 'var(--primary-light, #e3f2fd)' : 'transparent', - border: isActive ? '1px solid var(--primary-color, #1976d2)20' : '1px solid transparent', - transition: 'background 0.15s', - position: 'relative', - }} - onMouseEnter={e => { - if (!isActive) e.currentTarget.style.background = '#f5f5f5'; - const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement; - if (actions) actions.style.opacity = '1'; - }} - onMouseLeave={e => { - if (!isActive) e.currentTarget.style.background = 'transparent'; - const actions = e.currentTarget.querySelector('[data-actions]') as HTMLElement; - if (actions) actions.style.opacity = '0'; - if (confirmDeleteId === conv.id) setConfirmDeleteId(null); - }} - > - {/* Name row */} -
- {isEditing ? ( - setEditName(e.target.value)} - onBlur={() => _commitRename(conv.id)} - onKeyDown={e => _handleKeyDown(e, conv.id)} - onClick={e => e.stopPropagation()} - style={{ - flex: 1, minWidth: 0, fontSize: 13, fontWeight: 600, - padding: '1px 4px', borderRadius: 3, - border: '1px solid var(--primary-color, #1976d2)', - outline: 'none', background: '#fff', - }} - /> - ) : ( - <> - - {_formatTime(conv.lastActivity)} - - { e.stopPropagation(); _startEditing(conv); }} - title={conv.name} - > - {conv.name} - - - )} - - {/* Action buttons (visible on hover) */} - {!isEditing && ( - - - {conv.status === 'archived' ? ( - - ) : ( - - )} - {confirmDeleteId === conv.id ? ( - - - - - ) : ( - - )} - - )} -
- -
- ); - })} - - {/* Pagination */} - {totalPages > 1 && ( -
- - {page + 1} / {totalPages} - -
- )} -
- ); -}; - -const _actionBtnStyle: React.CSSProperties = { - background: 'none', - border: 'none', - cursor: 'pointer', - fontSize: 11, - color: '#999', - padding: '0 2px', -}; - -const _pageBtnStyle: React.CSSProperties = { - background: 'none', - border: '1px solid #ddd', - borderRadius: 4, - cursor: 'pointer', - padding: '2px 8px', - color: '#666', -}; diff --git a/src/pages/views/workspace/DataSourcePanel.tsx b/src/pages/views/workspace/DataSourcePanel.tsx deleted file mode 100644 index e343792..0000000 --- a/src/pages/views/workspace/DataSourcePanel.tsx +++ /dev/null @@ -1,942 +0,0 @@ -/** - * DataSourcePanel -- Browse external data sources as a lazy-loading tree. - * - * Tree structure: - * UserConnection (Level 1, loaded on mount) - * └─ Service (Level 2, loaded when connection expanded) - * └─ Folder / Site / File (Level 3+, loaded when service/folder expanded) - * - * Each folder node can be added as a DataSource for this workspace instance. - */ - -import React, { useEffect, useState, useCallback, useRef } from 'react'; -import api from '../../../api'; -import { getPageIcon } from '../../../config/pageRegistry'; -import type { DataSource, FeatureDataSource } from './useWorkspace'; - -/* ─── Types ─────────────────────────────────────────────────────────── */ - -interface TreeNode { - key: string; - label: string; - icon: string; - type: 'connection' | 'service' | 'folder' | 'file'; - expanded: boolean; - loading: boolean; - children: TreeNode[] | null; - connectionId: string; - service?: string; - path?: string; - /** Breadcrumb for tooltips and persisted displayPath (service + folder segments) */ - displayPath?: string; - authority?: string; -} - -interface FeatureConnectionNode { - featureInstanceId: string; - featureCode: string; - mandateId?: string; - label: string; - icon: string; - tableCount: number; - expanded: boolean; - loading: boolean; - tables: FeatureTableNode[] | null; -} - -interface MandateGroupNode { - mandateId: string; - mandateLabel: string; - expanded: boolean; - featureConnections: FeatureConnectionNode[]; -} - -interface FeatureTableNode { - objectKey: string; - tableName: string; - label: Record; - fields: string[]; -} - -interface DataSourcePanelProps { - instanceId: string; - dataSources: DataSource[]; - featureDataSources: FeatureDataSource[]; - onRefresh: () => void; - onRefreshFeatureDataSources: () => void; -} - -/* ─── Icons ─────────────────────────────────────────────────────────── */ - -const _AUTHORITY_ICONS: Record = { - msft: '\uD83D\uDFE6', - google: '\uD83D\uDFE9', - 'local:ftp': '\uD83D\uDD17', - 'local:jira': '\uD83D\uDD27', -}; - -const _SERVICE_ICONS: Record = { - sharepoint: '\uD83D\uDCC1', - onedrive: '\u2601\uFE0F', - outlook: '\uD83D\uDCE7', - teams: '\uD83D\uDCAC', - drive: '\uD83D\uDCC2', - gmail: '\uD83D\uDCE8', - files: '\uD83D\uDCC2', -}; - -/* ─── Source colors & icons ──────────────────────────────────────────── */ - -const _SOURCE_COLORS: Record = { - sharepointFolder: '#0078d4', - onedriveFolder: '#0078d4', - outlookFolder: '#0078d4', - googleDriveFolder: '#34a853', - gmailFolder: '#ea4335', - ftpFolder: '#795548', -}; - -function _getSourceColor(sourceType: string): string { - return _SOURCE_COLORS[sourceType] || '#1976d2'; -} - -function _getSourceIcon(sourceType: string): string { - const map: Record = { - sharepointFolder: '\uD83D\uDCC1', - onedriveFolder: '\u2601\uFE0F', - outlookFolder: '\uD83D\uDCE7', - googleDriveFolder: '\uD83D\uDCC2', - gmailFolder: '\uD83D\uDCE8', - ftpFolder: '\uD83D\uDD17', - }; - return map[sourceType] || '\uD83D\uDCC1'; -} - -function _mapFeatureTreeUpdate( - prev: MandateGroupNode[], - featureInstanceId: string, - updater: (n: FeatureConnectionNode) => FeatureConnectionNode, -): MandateGroupNode[] { - return prev.map(g => ({ - ...g, - featureConnections: g.featureConnections.map(n => - n.featureInstanceId === featureInstanceId ? updater(n) : n - ), - })); -} - -function _findFeatureInstanceMeta( - groups: MandateGroupNode[], - featureInstanceId: string, -): { mandateLabel: string; instanceLabel: string } | null { - for (const g of groups) { - const fc = g.featureConnections.find(f => f.featureInstanceId === featureInstanceId); - if (fc) return { mandateLabel: g.mandateLabel, instanceLabel: fc.label }; - } - return null; -} - -function _personalDataSourceHoverTitle(connLabel: string, ds: DataSource): string { - const pathPart = (ds.displayPath && ds.displayPath.trim()) || ds.label || ds.path || ''; - return pathPart ? `${connLabel} / ${pathPart}` : connLabel; -} - -function _featureDataSourceHoverTitle( - meta: { mandateLabel: string; instanceLabel: string } | null, - fds: FeatureDataSource, -): string { - const parts: string[] = []; - if (meta) { - parts.push(meta.mandateLabel, meta.instanceLabel); - } - const labelPart = fds.label && fds.tableName && fds.label !== fds.tableName - ? `${fds.label} (${fds.tableName})` - : (fds.label || fds.tableName); - parts.push(labelPart); - if (fds.objectKey && fds.objectKey !== labelPart && !labelPart.includes(fds.objectKey)) { - parts.push(fds.objectKey); - } - return parts.join(' / '); -} - -/* ─── Component ─────────────────────────────────────────────────────── */ - -export const DataSourcePanel: React.FC = ({ - instanceId, - dataSources, - featureDataSources, - onRefresh, - onRefreshFeatureDataSources, -}) => { - const [tree, setTree] = useState([]); - const [loadingRoot, setLoadingRoot] = useState(false); - const [addingPath, setAddingPath] = useState(null); - const [featureTree, setFeatureTree] = useState([]); - const [loadingFeatures, setLoadingFeatures] = useState(false); - const [addingFeatureKey, setAddingFeatureKey] = useState(null); - const mountedRef = useRef(true); - useEffect(() => { mountedRef.current = true; return () => { mountedRef.current = false; }; }, []); - - /* ── Load Level 1: UserConnections ── */ - const _loadConnections = useCallback(() => { - if (!instanceId) return; - setLoadingRoot(true); - api.get(`/api/workspace/${instanceId}/connections`) - .then(res => { - if (!mountedRef.current) return; - const conns = res.data.connections || []; - const nodes: TreeNode[] = conns - .filter((c: any) => c.status === 'active') - .map((c: any) => ({ - key: `conn-${c.id}`, - label: c.externalEmail || c.externalUsername || c.authority, - icon: _AUTHORITY_ICONS[c.authority] || '\uD83D\uDD17', - type: 'connection' as const, - expanded: false, - loading: false, - children: null, - connectionId: c.id, - authority: c.authority, - })); - setTree(nodes); - }) - .catch(() => { if (mountedRef.current) setTree([]); }) - .finally(() => { if (mountedRef.current) setLoadingRoot(false); }); - }, [instanceId]); - - useEffect(() => { _loadConnections(); }, [_loadConnections]); - - /* ── Generic tree update helper ── */ - const _updateNode = useCallback((key: string, updater: (node: TreeNode) => TreeNode) => { - setTree(prev => _mapTree(prev, key, updater)); - }, []); - - /* ── Toggle expand/collapse ── */ - const _toggleNode = useCallback(async (node: TreeNode) => { - if (node.expanded) { - _updateNode(node.key, n => ({ ...n, expanded: false })); - return; - } - - if (node.children !== null) { - _updateNode(node.key, n => ({ ...n, expanded: true })); - return; - } - - _updateNode(node.key, n => ({ ...n, loading: true, expanded: true })); - - try { - let children: TreeNode[] = []; - - if (node.type === 'connection') { - children = await _loadServices(instanceId, node.connectionId); - } else if (node.type === 'service' || node.type === 'folder') { - children = await _browseService( - instanceId, - node.connectionId, - node.service!, - node.path || '/', - node.displayPath || node.label, - ); - } - - if (mountedRef.current) { - _updateNode(node.key, n => ({ ...n, loading: false, children })); - } - } catch { - if (mountedRef.current) { - _updateNode(node.key, n => ({ ...n, loading: false, children: [] })); - } - } - }, [instanceId, _updateNode]); - - /* ── Add as DataSource ── */ - const _addAsDataSource = useCallback(async (node: TreeNode) => { - if (!node.service || !node.connectionId) return; - setAddingPath(node.key); - try { - const sourceTypeMap: Record = { - sharepoint: 'sharepointFolder', - onedrive: 'onedriveFolder', - outlook: 'outlookFolder', - drive: 'googleDriveFolder', - gmail: 'gmailFolder', - files: 'ftpFolder', - }; - await api.post(`/api/workspace/${instanceId}/datasources`, { - connectionId: node.connectionId, - sourceType: sourceTypeMap[node.service] || node.service, - path: node.path || '/', - label: node.label, - displayPath: node.displayPath || node.label, - }); - onRefresh(); - } catch (err) { - console.error('Failed to add data source:', err); - } finally { - if (mountedRef.current) setAddingPath(null); - } - }, [instanceId, onRefresh]); - - /* ── Remove DataSource ── */ - const _removeDatasource = useCallback(async (dsId: string) => { - try { - await api.delete(`/api/workspace/${instanceId}/datasources/${dsId}`); - onRefresh(); - } catch (err) { - console.error('Failed to remove data source:', err); - } - }, [instanceId, onRefresh]); - - /* ── Check if a path is already added ── */ - const _isAdded = useCallback((connectionId: string, _service: string | undefined, path: string | undefined): boolean => { - return dataSources.some(ds => - ds.connectionId === connectionId && ds.path === (path || '/'), - ); - }, [dataSources]); - - /* ── Feature Connections: Load Level 1 ── */ - const _loadFeatureConnections = useCallback(() => { - if (!instanceId) return; - setLoadingFeatures(true); - api.get(`/api/workspace/${instanceId}/feature-connections`) - .then(res => { - if (!mountedRef.current) return; - const groups = res.data.featureConnectionsByMandate || []; - setFeatureTree(groups.map((g: any) => ({ - mandateId: g.mandateId, - mandateLabel: g.mandateLabel || g.mandateId, - expanded: true, - featureConnections: (g.featureConnections || []).map((c: any) => ({ - featureInstanceId: c.featureInstanceId, - featureCode: c.featureCode, - mandateId: c.mandateId, - label: c.label, - icon: c.icon || '\uD83D\uDDC3\uFE0F', - tableCount: c.tableCount || 0, - expanded: false, - loading: false, - tables: null, - })), - }))); - }) - .catch(() => { if (mountedRef.current) setFeatureTree([]); }) - .finally(() => { if (mountedRef.current) setLoadingFeatures(false); }); - }, [instanceId]); - - useEffect(() => { _loadFeatureConnections(); }, [_loadFeatureConnections]); - - /* ── Feature Connections: Toggle mandate group ── */ - const _toggleMandateGroup = useCallback((mandateId: string) => { - setFeatureTree(prev => prev.map(g => - g.mandateId === mandateId ? { ...g, expanded: !g.expanded } : g - )); - }, []); - - /* ── Feature Connections: Toggle expand ── */ - const _toggleFeatureNode = useCallback(async (node: FeatureConnectionNode) => { - if (node.expanded) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: false }))); - return; - } - - if (node.tables !== null) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ ...n, expanded: true }))); - return; - } - - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ - ...n, loading: true, expanded: true, - }))); - - try { - const res = await api.get(`/api/workspace/${instanceId}/feature-connections/${node.featureInstanceId}/tables`); - const tables: FeatureTableNode[] = (res.data.tables || []).map((t: any) => ({ - objectKey: t.objectKey, - tableName: t.tableName, - label: t.label || {}, - fields: t.fields || [], - })); - if (mountedRef.current) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ - ...n, loading: false, tables, - }))); - } - } catch { - if (mountedRef.current) { - setFeatureTree(prev => _mapFeatureTreeUpdate(prev, node.featureInstanceId, n => ({ - ...n, loading: false, tables: [], - }))); - } - } - }, [instanceId]); - - /* ── Feature: Add table as FeatureDataSource ── */ - const _addFeatureTable = useCallback(async (node: FeatureConnectionNode, table: FeatureTableNode) => { - const key = `${node.featureInstanceId}-${table.tableName}`; - setAddingFeatureKey(key); - try { - await api.post(`/api/workspace/${instanceId}/feature-datasources`, { - featureInstanceId: node.featureInstanceId, - featureCode: node.featureCode, - tableName: table.tableName, - objectKey: table.objectKey, - label: table.label?.en || table.label?.de || table.tableName, - }); - onRefreshFeatureDataSources(); - } catch (err) { - console.error('Failed to add feature data source:', err); - } finally { - if (mountedRef.current) setAddingFeatureKey(null); - } - }, [instanceId, onRefreshFeatureDataSources]); - - /* ── Feature: Remove FeatureDataSource ── */ - const _removeFeatureDataSource = useCallback(async (fdsId: string) => { - try { - await api.delete(`/api/workspace/${instanceId}/feature-datasources/${fdsId}`); - onRefreshFeatureDataSources(); - } catch (err) { - console.error('Failed to remove feature data source:', err); - } - }, [instanceId, onRefreshFeatureDataSources]); - - /* ── Feature: check if table already added ── */ - const _isFeatureTableAdded = useCallback((featureInstanceId: string, tableName: string): boolean => { - return featureDataSources.some(fds => - fds.featureInstanceId === featureInstanceId && fds.tableName === tableName, - ); - }, [featureDataSources]); - - return ( -
- {/* Active DataSources */} - {dataSources.length > 0 && ( -
-
- Active Personal Sources -
- {dataSources.map(ds => { - const connColor = _getSourceColor(ds.sourceType); - const connNode = tree.find(n => n.connectionId === ds.connectionId); - const connLabel = connNode?.label || ds.connectionId; - const folder = ds.label || ds.path || ds.id; - return ( -
- {_getSourceIcon(ds.sourceType)} - - {connLabel} – {folder} - - -
- ); - })} -
-
- )} - - {/* Tree header */} -
- - Browse Sources - - -
- - {/* Tree */} - {loadingRoot && tree.length === 0 && ( -
- Loading connections... -
- )} - - {!loadingRoot && tree.length === 0 && ( -
- No active connections found. -
- )} - - {tree.map(node => ( - <_TreeNodeView - key={node.key} - node={node} - depth={0} - onToggle={_toggleNode} - onAdd={_addAsDataSource} - isAdded={_isAdded} - addingPath={addingPath} - /> - ))} - - {/* ── Feature Data Section ── */} -
- - {/* Active Feature Data Sources */} - {featureDataSources.length > 0 && ( -
-
- Active Feature Sources -
- {featureDataSources.map(fds => { - const meta = _findFeatureInstanceMeta(featureTree, fds.featureInstanceId); - const fdsConnLabel = meta?.instanceLabel || fds.tableName; - return ( -
- - {getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'} - - - {fdsConnLabel} – {fds.tableName} - - -
- ); })} -
-
- )} - - {/* Feature Connections Tree */} -
- - Feature Data - - -
- - {loadingFeatures && featureTree.length === 0 && ( -
- Loading feature instances... -
- )} - - {!loadingFeatures && featureTree.length === 0 && ( -
- No feature instances found. -
- )} - - {featureTree.map(g => ( - <_MandateGroupView - key={g.mandateId} - group={g} - onToggleGroup={_toggleMandateGroup} - onToggleFeature={_toggleFeatureNode} - onAddTable={_addFeatureTable} - isTableAdded={_isFeatureTableAdded} - addingKey={addingFeatureKey} - /> - ))} -
- ); -}; - -/* ─── TreeNodeView (recursive) ──────────────────────────────────────── */ - -interface TreeNodeViewProps { - node: TreeNode; - depth: number; - onToggle: (node: TreeNode) => void; - onAdd: (node: TreeNode) => void; - isAdded: (connectionId: string, service: string | undefined, path: string | undefined) => boolean; - addingPath: string | null; -} - -const _TreeNodeView: React.FC = ({ - node, depth, onToggle, onAdd, isAdded, addingPath, -}) => { - const [hovered, setHovered] = useState(false); - const hasChildren = node.type !== 'file'; - const chevron = hasChildren - ? (node.expanded ? '\u25BE' : '\u25B8') - : '\u00A0\u00A0'; - const canAdd = node.type === 'folder' || node.type === 'service'; - const alreadyAdded = canAdd && isAdded(node.connectionId, node.service, node.path); - const isAdding = addingPath === node.key; - - return ( -
-
{ if (hasChildren) onToggle(node); }} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - style={{ - display: 'flex', - alignItems: 'center', - gap: 4, - paddingLeft: depth * 16 + 4, - paddingRight: 4, - paddingTop: 3, - paddingBottom: 3, - cursor: hasChildren ? 'pointer' : 'default', - borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', - transition: 'background 0.1s', - userSelect: 'none', - }} - > - - {node.loading ? _Spinner() : chevron} - - {node.icon} - - {node.label} - - {canAdd && hovered && !alreadyAdded && ( - - )} - {canAdd && alreadyAdded && ( - - {'\u2713'} - - )} -
- - {/* Children */} - {node.expanded && node.children && node.children.length > 0 && ( -
- {node.children.map(child => ( - <_TreeNodeView - key={child.key} - node={child} - depth={depth + 1} - onToggle={onToggle} - onAdd={onAdd} - isAdded={isAdded} - addingPath={addingPath} - /> - ))} -
- )} - - {node.expanded && node.children && node.children.length === 0 && !node.loading && ( -
- (empty) -
- )} -
- ); -}; - -/* ─── MandateGroupView (mandate + feature instances) ───────────────── */ - -interface MandateGroupViewProps { - group: MandateGroupNode; - onToggleGroup: (mandateId: string) => void; - onToggleFeature: (node: FeatureConnectionNode) => void; - onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; - isTableAdded: (featureInstanceId: string, tableName: string) => boolean; - addingKey: string | null; -} - -const _MandateGroupView: React.FC = ({ - group, onToggleGroup, onToggleFeature, onAddTable, isTableAdded, addingKey, -}) => { - const [hovered, setHovered] = useState(false); - const chevron = group.expanded ? '\u25BE' : '\u25B8'; - - return ( -
-
onToggleGroup(group.mandateId)} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - style={{ - display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, - cursor: 'pointer', borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', - transition: 'background 0.1s', userSelect: 'none', - }} - > - - {chevron} - - - {group.mandateLabel} - -
- - {group.expanded && ( -
- {group.featureConnections.map(fNode => ( - <_FeatureNodeView - key={fNode.featureInstanceId} - node={fNode} - onToggle={onToggleFeature} - onAddTable={onAddTable} - isTableAdded={isTableAdded} - addingKey={addingKey} - /> - ))} -
- )} -
- ); -}; - -/* ─── FeatureNodeView (feature instance + tables) ─────────────────── */ - -interface FeatureNodeViewProps { - node: FeatureConnectionNode; - onToggle: (node: FeatureConnectionNode) => void; - onAddTable: (node: FeatureConnectionNode, table: FeatureTableNode) => void; - isTableAdded: (featureInstanceId: string, tableName: string) => boolean; - addingKey: string | null; -} - -const _FeatureNodeView: React.FC = ({ - node, onToggle, onAddTable, isTableAdded, addingKey, -}) => { - const [hovered, setHovered] = useState(false); - const chevron = node.expanded ? '\u25BE' : '\u25B8'; - - return ( -
-
onToggle(node)} - onMouseEnter={() => setHovered(true)} - onMouseLeave={() => setHovered(false)} - style={{ - display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: 4, paddingRight: 4, paddingTop: 3, paddingBottom: 3, - cursor: 'pointer', borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', - transition: 'background 0.1s', userSelect: 'none', - }} - > - - {node.loading ? _Spinner() : chevron} - - - {getPageIcon(`feature.${node.featureCode}`) || '\uD83D\uDDC3\uFE0F'} - - - {node.label} - - - {node.tableCount} tables - -
- - {node.expanded && node.tables && node.tables.length > 0 && ( -
- {node.tables.map(table => ( - <_FeatureTableRow - key={table.objectKey} - featureNode={node} - table={table} - onAdd={onAddTable} - isAdded={isTableAdded(node.featureInstanceId, table.tableName)} - isAdding={addingKey === `${node.featureInstanceId}-${table.tableName}`} - /> - ))} -
- )} - - {node.expanded && node.tables && node.tables.length === 0 && !node.loading && ( -
- (no tables) -
- )} -
- ); -}; - -interface FeatureTableRowProps { - featureNode: FeatureConnectionNode; - table: FeatureTableNode; - onAdd: (node: FeatureConnectionNode, table: FeatureTableNode) => void; - isAdded: boolean; - isAdding: boolean; -} - -const _FeatureTableRow: React.FC = ({ - featureNode, table, onAdd, isAdded, isAdding, -}) => { - const [hovered, setHovered] = useState(false); - const tableLabel = table.label?.en || table.label?.de || table.tableName; - - return ( -
setHovered(true)} - onMouseLeave={() => setHovered(false)} - style={{ - display: 'flex', alignItems: 'center', gap: 4, - paddingLeft: 36, paddingRight: 4, paddingTop: 3, paddingBottom: 3, - borderRadius: 3, - background: hovered ? 'var(--hover-bg, #f5f5f5)' : 'transparent', - transition: 'background 0.1s', userSelect: 'none', - }} - title={`${table.tableName}: ${table.fields.join(', ')}`} - > - {'\uD83D\uDCC1'} - - {tableLabel} - - {hovered && !isAdded && ( - - )} - {isAdded && ( - - {'\u2713'} - - )} -
- ); -}; - -/* ─── Spinner (inline) ──────────────────────────────────────────────── */ - -function _Spinner(): React.ReactElement { - return ( - - ); -} - -/* ─── Data fetching ─────────────────────────────────────────────────── */ - -async function _loadServices(instanceId: string, connectionId: string): Promise { - const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/services`); - const services = res.data.services || []; - return services.map((s: any) => ({ - key: `svc-${connectionId}-${s.service}`, - label: s.label || s.service, - icon: _SERVICE_ICONS[s.service] || '\uD83D\uDCC2', - type: 'service' as const, - expanded: false, - loading: false, - children: null, - connectionId, - service: s.service, - path: '/', - displayPath: s.label || s.service, - })); -} - -async function _browseService( - instanceId: string, - connectionId: string, - service: string, - path: string, - parentDisplayPath: string | undefined, -): Promise { - const res = await api.get(`/api/workspace/${instanceId}/connections/${connectionId}/browse`, { - params: { service, path }, - }); - const items = res.data.items || []; - return items.map((entry: any, idx: number) => { - const seg = entry.name || ''; - const displayPath = parentDisplayPath - ? `${parentDisplayPath} / ${seg}` - : seg; - return { - key: `item-${connectionId}-${service}-${entry.path || idx}`, - label: entry.name, - icon: entry.isFolder ? '\uD83D\uDCC1' : _fileIcon(entry.name), - type: entry.isFolder ? 'folder' as const : 'file' as const, - expanded: false, - loading: false, - children: entry.isFolder ? null : [], - connectionId, - service, - path: entry.path, - displayPath, - }; - }); -} - -function _fileIcon(name: string): string { - const ext = name.split('.').pop()?.toLowerCase() || ''; - const map: Record = { - pdf: '\uD83D\uDCC4', doc: '\uD83D\uDCDD', docx: '\uD83D\uDCDD', - xls: '\uD83D\uDCCA', xlsx: '\uD83D\uDCCA', csv: '\uD83D\uDCCA', - ppt: '\uD83D\uDCC8', pptx: '\uD83D\uDCC8', - txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB', - png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F', - zip: '\uD83D\uDCE6', rar: '\uD83D\uDCE6', - mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', - mp4: '\uD83C\uDFAC', mov: '\uD83C\uDFAC', - }; - return map[ext] || '\uD83D\uDCC4'; -} - -/* ─── Tree map utility ──────────────────────────────────────────────── */ - -function _mapTree(nodes: TreeNode[], key: string, updater: (n: TreeNode) => TreeNode): TreeNode[] { - return nodes.map(n => { - if (n.key === key) return updater(n); - if (n.children) return { ...n, children: _mapTree(n.children, key, updater) }; - return n; - }); -} diff --git a/src/pages/views/workspace/FileBrowser.tsx b/src/pages/views/workspace/FileBrowser.tsx deleted file mode 100644 index 1ce992c..0000000 --- a/src/pages/views/workspace/FileBrowser.tsx +++ /dev/null @@ -1,252 +0,0 @@ -/** - * FileBrowser -- Folder-tree file browser for workspace. - * - * Uses useFileContext() for folders (shared state with Dateien page). - * Uses FolderTree with showFiles=true so folders and files render inline. - */ - -import React, { useState, useCallback, useRef, useMemo } from 'react'; -import api from '../../../api'; -import FolderTree from '../../../components/FolderTree/FolderTree'; -import type { FileNode } from '../../../components/FolderTree/FolderTree'; -import { useFileContext } from '../../../contexts/FileContext'; -import type { WorkspaceFile } from './useWorkspace'; - -interface FileBrowserProps { - instanceId: string; - files: WorkspaceFile[]; - onRefresh: () => void; - onFileSelect?: (fileId: string) => void; -} - -export const FileBrowser: React.FC = ({ - instanceId, - files, - onRefresh, - onFileSelect, -}) => { - const [searchQuery, setSearchQuery] = useState(''); - const [isDragOver, setIsDragOver] = useState(false); - const [uploading, setUploading] = useState(false); - const [selectedFolderId, setSelectedFolderId] = useState(null); - const fileInputRef = useRef(null); - - const { - folders, - refreshFolders, - handleCreateFolder, - handleRenameFolder, - handleDeleteFolder, - handleMoveFolder, - handleMoveFolders, - handleMoveFile, - handleMoveFiles: contextMoveFiles, - handleFileDelete, - handleDownloadFolder, - expandedFolderIds, - toggleFolderExpanded, - } = useFileContext(); - - const _folderNodes = useMemo(() => - folders.map(f => ({ - id: f.id, - name: f.name, - parentId: f.parentId ?? null, - })), - [folders], - ); - - const _fileNodes: FileNode[] = useMemo(() => { - let result: WorkspaceFile[] = files; - if (searchQuery.trim()) { - const q = searchQuery.toLowerCase(); - result = result.filter(f => - f.fileName.toLowerCase().includes(q) - || (f.tags || []).some((t: string) => t.toLowerCase().includes(q)), - ); - } - return result - .sort((a, b) => a.fileName.localeCompare(b.fileName)) - .map(f => ({ - id: f.id, - fileName: f.fileName, - mimeType: f.mimeType, - fileSize: f.fileSize, - folderId: f.folderId ?? null, - })); - }, [files, searchQuery]); - - const _refreshAll = useCallback(() => { - onRefresh(); - refreshFolders(); - }, [onRefresh, refreshFolders]); - - const _uploadFiles = useCallback(async (fileList: FileList | File[]) => { - if (!instanceId || uploading) return; - setUploading(true); - try { - for (const file of Array.from(fileList)) { - const formData = new FormData(); - formData.append('file', file); - formData.append('featureInstanceId', instanceId); - await api.post('/api/files/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - }); - } - _refreshAll(); - } catch (err) { - console.error('File upload failed:', err); - } finally { - setUploading(false); - } - }, [instanceId, uploading, _refreshAll]); - - const _handleDragOver = useCallback((e: React.DragEvent) => { - if (e.dataTransfer.types.includes('Files')) { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(true); - } - }, []); - - const _handleDragLeave = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - }, []); - - const _handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - e.stopPropagation(); - setIsDragOver(false); - if (e.dataTransfer.files.length > 0) { - _uploadFiles(e.dataTransfer.files); - } - }, [_uploadFiles]); - - const _handleFileInputChange = useCallback((e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - _uploadFiles(e.target.files); - e.target.value = ''; - } - }, [_uploadFiles]); - - const _onMoveFile = useCallback(async (fileId: string, targetFolderId: string | null) => { - await handleMoveFile(fileId, targetFolderId); - onRefresh(); - }, [handleMoveFile, onRefresh]); - - const _onMoveFiles = useCallback(async (fileIds: string[], targetFolderId: string | null) => { - await contextMoveFiles(fileIds, targetFolderId); - onRefresh(); - }, [contextMoveFiles, onRefresh]); - - const _onDeleteFolder = useCallback(async (folderId: string) => { - await handleDeleteFolder(folderId); - if (selectedFolderId === folderId) setSelectedFolderId(null); - onRefresh(); - }, [handleDeleteFolder, selectedFolderId, onRefresh]); - - const _onRenameFile = useCallback(async (fileId: string, newName: string) => { - await api.put(`/api/files/${fileId}`, { fileName: newName }); - onRefresh(); - }, [onRefresh]); - - const _onDeleteFile = useCallback(async (fileId: string) => { - await handleFileDelete(fileId); - onRefresh(); - }, [handleFileDelete, onRefresh]); - - const _onDeleteFiles = useCallback(async (fileIds: string[]) => { - await api.post('/api/files/batch-delete', { fileIds }); - onRefresh(); - }, [onRefresh]); - - const _onDeleteFolders = useCallback(async (folderIds: string[]) => { - await api.post('/api/files/batch-delete', { folderIds, recursiveFolders: true }); - refreshFolders(); - onRefresh(); - }, [refreshFolders, onRefresh]); - - return ( -
- {isDragOver && ( -
- Dateien hier ablegen -
- )} - - {/* Header */} -
- Files -
- - -
-
- - - - {/* Search */} - setSearchQuery(e.target.value)} - style={{ - width: '100%', padding: '6px 10px', fontSize: 12, borderRadius: 4, - border: '1px solid #ddd', boxSizing: 'border-box', - }} - /> - - {/* Folder tree with inline files */} - - - {_fileNodes.length === 0 && ( -
- {searchQuery ? 'Keine Dateien gefunden' : 'Keine Dateien. Drag & Drop zum Hochladen.'} -
- )} -
- ); -}; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index 9d91a75..9b16849 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -38,7 +38,7 @@ interface TreeItemDrop { interface WorkspaceInputProps { instanceId: string; - onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[]) => void; + onSend: (prompt: string, fileIds?: string[], dataSourceIds?: string[], featureDataSourceIds?: string[], options?: { requireNeutralization?: boolean }) => void; isProcessing: boolean; onStop: () => void; files: WorkspaceFile[]; @@ -84,6 +84,7 @@ export const WorkspaceInput: React.FC = ({ const [attachedFileIds, setAttachedFileIds] = useState([]); const [attachedDataSourceIds, setAttachedDataSourceIds] = useState([]); const [attachedFeatureDataSourceIds, setAttachedFeatureDataSourceIds] = useState([]); + const [neutralizeActive, setNeutralizeActive] = useState(false); const textareaRef = useRef(null); const promptBeforeVoiceRef = useRef(''); const finalizedTextRef = useRef(''); @@ -122,12 +123,13 @@ export const WorkspaceInput: React.FC = ({ if (!trimmed || isProcessing) return; const inlineFileIds = _extractFileRefs(trimmed); const allFileIds = [...new Set([...attachedFileIds, ...inlineFileIds])]; - onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds); + const options = neutralizeActive ? { requireNeutralization: true } : undefined; + onSend(trimmed, allFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, options); setPrompt(''); setShowAutocomplete(false); setShowSourcePicker(false); setAttachedFileIds([]); - }, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, onSend]); + }, [prompt, isProcessing, _extractFileRefs, attachedFileIds, attachedDataSourceIds, attachedFeatureDataSourceIds, neutralizeActive, onSend]); const _handleKeyDown = useCallback( (e: React.KeyboardEvent) => { @@ -705,6 +707,21 @@ export const WorkspaceInput: React.FC = ({ )}
+ + {isProcessing ? (
)} - Date: Sat, 28 Mar 2026 18:13:18 +0100 Subject: [PATCH 04/11] BREAKING CHANGE API and persisted records use PowerOnModel system fields: - sysCreatedAt, sysCreatedBy, sysModifiedAt, sysModifiedBy Removed legacy JSON/DB field names: - _createdAt, _createdBy, _modifiedAt, _modifiedBy Frontend (frontend_nyla) and gateway call sites were updated accordingly. Database: - Bootstrap runs idempotent backfill (_migrateSystemFieldColumns) from old underscore columns and selected business duplicates into sys* where sys* IS NULL. - Re-run app bootstrap against each PostgreSQL database after deploy. - Optional: DROP INDEX IF EXISTS "idx_invitation_createdby" if an old index remains; new index: idx_invitation_syscreatedby on Invitation(sysCreatedBy). Tests: - RBAC integration tests aligned with current GROUP mandate filter and UserMandate-based UserConnection GROUP clause; buildRbacWhereClause(..., mandateId=...) must be passed explicitly (same as production request context). --- src/api/automation2Api.ts | 2 +- src/api/automationApi.ts | 12 ++--- src/api/promptApi.ts | 2 +- src/api/realEstateApi.ts | 8 +-- src/api/trusteeApi.ts | 52 +++++++++---------- src/hooks/useAdminMandates.ts | 4 +- src/hooks/useAdminRbacRoles.ts | 4 +- src/hooks/useAdminRbacRules.ts | 4 +- src/hooks/useAutomations.ts | 2 +- src/hooks/useInstancePermissions.tsx | 12 ++--- src/hooks/usePrompts.ts | 6 +-- src/hooks/useRealEstate.ts | 4 +- src/hooks/useTrustee.ts | 4 +- src/hooks/useTrusteeAccess.ts | 2 +- src/hooks/useTrusteeContracts.ts | 2 +- src/hooks/useTrusteeDocuments.ts | 2 +- src/hooks/useTrusteeOrganisations.ts | 2 +- src/hooks/useTrusteePositionDocuments.ts | 2 +- src/hooks/useTrusteePositions.ts | 2 +- src/hooks/useTrusteeRoles.ts | 2 +- src/hooks/useUsers.ts | 4 +- src/hooks/useWorkflows.ts | 2 +- src/pages/basedata/ConnectionsPage.tsx | 2 +- src/pages/basedata/FilesPage.tsx | 4 +- src/pages/basedata/PromptsPage.tsx | 8 +-- .../automation/AutomationDefinitionsView.tsx | 6 +-- .../automation/AutomationTemplatesView.tsx | 2 +- .../realestate/RealEstateParcelsView.tsx | 2 +- .../realestate/RealEstateProjectsView.tsx | 2 +- .../views/trustee/TrusteeDocumentsView.tsx | 2 +- .../trustee/TrusteePositionDocumentsView.tsx | 4 +- .../views/trustee/TrusteePositionsView.tsx | 4 +- .../views/workspace/NeutralizationPanel.tsx | 2 +- src/types/mandate.ts | 4 +- 34 files changed, 89 insertions(+), 89 deletions(-) diff --git a/src/api/automation2Api.ts b/src/api/automation2Api.ts index f215f7e..86c530c 100644 --- a/src/api/automation2Api.ts +++ b/src/api/automation2Api.ts @@ -258,7 +258,7 @@ export interface Automation2Task { result?: Record; /** Workflow label (enriched by API) */ workflowLabel?: string; - /** Unix timestamp ms (from _createdAt) */ + /** Unix timestamp ms (from sysCreatedAt) */ createdAt?: number; /** Optional due date - configurable in future */ dueAt?: number; diff --git a/src/api/automationApi.ts b/src/api/automationApi.ts index 80d44cd..955ada7 100644 --- a/src/api/automationApi.ts +++ b/src/api/automationApi.ts @@ -18,9 +18,9 @@ export interface Automation { nextExecution?: number; executionLogs?: AutomationLog[]; allowedProviders?: string[]; - _createdAt?: number; + sysCreatedAt?: number; _updatedAt?: number; - _createdByUserName?: string; + sysCreatedByUserName?: string; mandateName?: string; featureInstanceName?: string; [key: string]: any; @@ -48,9 +48,9 @@ export interface AutomationTemplate { label: TextMultilingual; overview?: TextMultilingual; template: string; // JSON string with {{KEY:...}} placeholders - _createdAt?: number; - _createdBy?: string; - _createdByUserName?: string; + sysCreatedAt?: number; + sysCreatedBy?: string; + sysCreatedByUserName?: string; } // Workflow action definition from backend @@ -301,7 +301,7 @@ export async function fetchAutomationTemplateById( */ export async function createAutomationTemplateApi( request: ApiRequestFunction, - templateData: Omit + templateData: Omit ): Promise { return await request({ url: '/api/automation-templates', diff --git a/src/api/promptApi.ts b/src/api/promptApi.ts index 3b5a000..094dcdc 100644 --- a/src/api/promptApi.ts +++ b/src/api/promptApi.ts @@ -9,7 +9,7 @@ export interface Prompt { mandateId: string; content: string; name: string; - _createdBy?: string; + sysCreatedBy?: string; _hideDelete?: boolean; [key: string]: any; // Allow additional properties } diff --git a/src/api/realEstateApi.ts b/src/api/realEstateApi.ts index 08d8ecc..c043485 100644 --- a/src/api/realEstateApi.ts +++ b/src/api/realEstateApi.ts @@ -23,8 +23,8 @@ export interface RealEstateProject { featureInstanceId?: string; perimeter?: any; parzellen?: RealEstateParcel[]; - _createdAt?: number; - _modifiedAt?: number; + sysCreatedAt?: number; + sysModifiedAt?: number; [key: string]: any; } @@ -38,8 +38,8 @@ export interface RealEstateParcel { plz?: string; perimeter?: any; bauzone?: string; - _createdAt?: number; - _modifiedAt?: number; + sysCreatedAt?: number; + sysModifiedAt?: number; [key: string]: any; } diff --git a/src/api/trusteeApi.ts b/src/api/trusteeApi.ts index 4230612..cc8920b 100644 --- a/src/api/trusteeApi.ts +++ b/src/api/trusteeApi.ts @@ -18,10 +18,10 @@ export interface TrusteeOrganisation { label: string; enabled: boolean; mandateId?: string; - _createdAt?: number; - _modifiedAt?: number; - _createdBy?: string; - _modifiedBy?: string; + sysCreatedAt?: number; + sysModifiedAt?: number; + sysCreatedBy?: string; + sysModifiedBy?: string; [key: string]: any; } @@ -29,10 +29,10 @@ export interface TrusteeRole { id: string; desc: string; mandateId?: string; - _createdAt?: number; - _modifiedAt?: number; - _createdBy?: string; - _modifiedBy?: string; + sysCreatedAt?: number; + sysModifiedAt?: number; + sysCreatedBy?: string; + sysModifiedBy?: string; [key: string]: any; } @@ -43,10 +43,10 @@ export interface TrusteeAccess { userId: string; contractId?: string | null; mandateId?: string; - _createdAt?: number; - _modifiedAt?: number; - _createdBy?: string; - _modifiedBy?: string; + sysCreatedAt?: number; + sysModifiedAt?: number; + sysCreatedBy?: string; + sysModifiedBy?: string; [key: string]: any; } @@ -56,10 +56,10 @@ export interface TrusteeContract { label: string; enabled: boolean; mandateId?: string; - _createdAt?: number; - _modifiedAt?: number; - _createdBy?: string; - _modifiedBy?: string; + sysCreatedAt?: number; + sysModifiedAt?: number; + sysCreatedBy?: string; + sysModifiedBy?: string; [key: string]: any; } @@ -71,10 +71,10 @@ export interface TrusteeDocument { documentMimeType: string; documentData?: any; mandateId?: string; - _createdAt?: number; - _modifiedAt?: number; - _createdBy?: string; - _modifiedBy?: string; + sysCreatedAt?: number; + sysModifiedAt?: number; + sysCreatedBy?: string; + sysModifiedBy?: string; [key: string]: any; } @@ -98,10 +98,10 @@ export interface TrusteePosition { costCenter?: string; bookingReference?: string; mandateId?: string; - _createdAt?: number; - _modifiedAt?: number; - _createdBy?: string; - _modifiedBy?: string; + sysCreatedAt?: number; + sysModifiedAt?: number; + sysCreatedBy?: string; + sysModifiedBy?: string; [key: string]: any; } @@ -696,8 +696,8 @@ export interface TrusteePositionDocument { documentId: string; mandateId?: string; featureInstanceId?: string; - _createdAt?: number; - _modifiedAt?: number; + sysCreatedAt?: number; + sysModifiedAt?: number; [key: string]: any; } diff --git a/src/hooks/useAdminMandates.ts b/src/hooks/useAdminMandates.ts index 010de50..dcf66a4 100644 --- a/src/hooks/useAdminMandates.ts +++ b/src/hooks/useAdminMandates.ts @@ -165,7 +165,7 @@ export function useMandates() { return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -305,7 +305,7 @@ export function useMandates() { return false; } // Filter out ID fields and other auto-generated fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useAdminRbacRoles.ts b/src/hooks/useAdminRbacRoles.ts index 7260f6b..9134eb7 100644 --- a/src/hooks/useAdminRbacRoles.ts +++ b/src/hooks/useAdminRbacRoles.ts @@ -206,7 +206,7 @@ export function useRbacRoles() { return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields - const nonEditableFields = ['id', 'roleId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'roleId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -346,7 +346,7 @@ export function useRbacRoles() { return false; } // Filter out ID fields and other auto-generated fields - const nonEditableFields = ['id', 'roleId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'roleId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useAdminRbacRules.ts b/src/hooks/useAdminRbacRules.ts index 12ca79b..4daab54 100644 --- a/src/hooks/useAdminRbacRules.ts +++ b/src/hooks/useAdminRbacRules.ts @@ -182,7 +182,7 @@ export function useRbacRules() { return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields - const nonEditableFields = ['id', 'ruleId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'ruleId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -322,7 +322,7 @@ export function useRbacRules() { return false; } // Filter out ID fields and other auto-generated fields - const nonEditableFields = ['id', 'ruleId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'ruleId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useAutomations.ts b/src/hooks/useAutomations.ts index 28897ac..80cf737 100644 --- a/src/hooks/useAutomations.ts +++ b/src/hooks/useAutomations.ts @@ -536,7 +536,7 @@ export function useAutomationTemplates() { return await fetchAutomationTemplateById(request, templateId); }, [request]); - const createTemplate = useCallback(async (data: Omit) => { + const createTemplate = useCallback(async (data: Omit) => { return await createAutomationTemplateApi(request, data); }, [request]); diff --git a/src/hooks/useInstancePermissions.tsx b/src/hooks/useInstancePermissions.tsx index 3d13ca7..048c27c 100644 --- a/src/hooks/useInstancePermissions.tsx +++ b/src/hooks/useInstancePermissions.tsx @@ -83,11 +83,11 @@ export function useTablePermission(tableName: string) { canDelete: hasAccess(permission.delete), // Record-basierte Prüfungen - canReadRecord: (record: { _createdBy?: string }) => + canReadRecord: (record: { sysCreatedBy?: string }) => canAccessRecord(permission.read, record, userId), - canUpdateRecord: (record: { _createdBy?: string }) => + canUpdateRecord: (record: { sysCreatedBy?: string }) => canAccessRecord(permission.update, record, userId), - canDeleteRecord: (record: { _createdBy?: string }) => + canDeleteRecord: (record: { sysCreatedBy?: string }) => canAccessRecord(permission.delete, record, userId), }; } @@ -296,7 +296,7 @@ export function useInstancePermissions(): InstancePermissions | undefined { */ export function useCanEditRecord( tableName: string, - record: { _createdBy?: string } | undefined, + record: { sysCreatedBy?: string } | undefined, userId: string ): boolean { const { update } = useTablePermission(tableName); @@ -311,7 +311,7 @@ export function useCanEditRecord( */ export function useCanDeleteRecord( tableName: string, - record: { _createdBy?: string } | undefined, + record: { sysCreatedBy?: string } | undefined, userId: string ): boolean { const { delete: deleteLevel } = useTablePermission(tableName); @@ -329,7 +329,7 @@ interface PermissionGateProps { table?: string; view?: string; action?: 'view' | 'read' | 'create' | 'update' | 'delete'; - record?: { _createdBy?: string }; + record?: { sysCreatedBy?: string }; children: React.ReactNode; fallback?: React.ReactNode; } diff --git a/src/hooks/usePrompts.ts b/src/hooks/usePrompts.ts index bc5c3a5..0454e37 100644 --- a/src/hooks/usePrompts.ts +++ b/src/hooks/usePrompts.ts @@ -157,7 +157,7 @@ export function usePrompts() { return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -367,7 +367,7 @@ export function usePrompts() { return false; } // Filter out ID fields and other auto-generated fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -530,7 +530,7 @@ export function usePromptOperations() { try { // Pass all provided fields (supports partial inline updates like isSystem toggle) - const { id, mandateId, _createdBy, _createdAt, _modifiedAt, _permissions, ...requestBody } = updateData; + const { id, mandateId, sysCreatedBy, sysCreatedAt, sysModifiedAt, _permissions, ...requestBody } = updateData; const updatedPrompt = await updatePromptApi(request, promptId, requestBody as UpdatePromptData); diff --git a/src/hooks/useRealEstate.ts b/src/hooks/useRealEstate.ts index c710d74..35e320c 100644 --- a/src/hooks/useRealEstate.ts +++ b/src/hooks/useRealEstate.ts @@ -165,7 +165,7 @@ function _createRealEstateEntityHook(config: RealEstat .filter(attr => { if (attr.readonly === true || attr.editable === false) return false; if (attr.name === 'id') return false; - const nonEditable = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt']; + const nonEditable = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt']; return !nonEditable.includes(attr.name); }) .map(attr => { @@ -210,7 +210,7 @@ function _createRealEstateEntityHook(config: RealEstat const generateCreateFieldsFromAttributes = useCallback(() => { if (!attributes || attributes.length === 0) return []; return attributes - .filter(attr => !['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name)) + .filter(attr => !['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt', 'mandateId', 'featureInstanceId'].includes(attr.name)) .map(attr => { let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'multiselect' | 'readonly' | 'number' = 'string'; let options: Array<{ value: string | number; label: string }> | undefined; diff --git a/src/hooks/useTrustee.ts b/src/hooks/useTrustee.ts index 244a45e..08ba6d6 100644 --- a/src/hooks/useTrustee.ts +++ b/src/hooks/useTrustee.ts @@ -218,7 +218,7 @@ function _createTrusteeEntityHook(config: TrusteeEntit if (attr.name === 'id') { return false; } - const nonEditableFields = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt']; + const nonEditableFields = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -284,7 +284,7 @@ function _createTrusteeEntityHook(config: TrusteeEntit return attributes .filter(attr => { - const systemFields = ['_createdBy', '_createdAt', '_modifiedBy', '_modifiedAt', 'mandateId']; + const systemFields = ['sysCreatedBy', 'sysCreatedAt', 'sysModifiedBy', 'sysModifiedAt', 'mandateId']; return !systemFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteeAccess.ts b/src/hooks/useTrusteeAccess.ts index 3208aef..6e1db73 100644 --- a/src/hooks/useTrusteeAccess.ts +++ b/src/hooks/useTrusteeAccess.ts @@ -175,7 +175,7 @@ export function useTrusteeAccess() { if (attr.readonly === true || attr.editable === false) { return false; } - const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteeContracts.ts b/src/hooks/useTrusteeContracts.ts index 8aa9f0c..0b628c1 100644 --- a/src/hooks/useTrusteeContracts.ts +++ b/src/hooks/useTrusteeContracts.ts @@ -175,7 +175,7 @@ export function useTrusteeContracts() { if (attr.readonly === true || attr.editable === false) { return false; } - const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteeDocuments.ts b/src/hooks/useTrusteeDocuments.ts index 9dcb79b..2453b0d 100644 --- a/src/hooks/useTrusteeDocuments.ts +++ b/src/hooks/useTrusteeDocuments.ts @@ -176,7 +176,7 @@ export function useTrusteeDocuments() { return false; } // documentData is handled separately (binary upload) - const nonEditableFields = ['id', 'documentData', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['id', 'documentData', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteeOrganisations.ts b/src/hooks/useTrusteeOrganisations.ts index 8f94f6a..11eb9d1 100644 --- a/src/hooks/useTrusteeOrganisations.ts +++ b/src/hooks/useTrusteeOrganisations.ts @@ -174,7 +174,7 @@ export function useTrusteeOrganisations() { if (attr.readonly === true || attr.editable === false) { return false; } - const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteePositionDocuments.ts b/src/hooks/useTrusteePositionDocuments.ts index 7661d06..e55d053 100644 --- a/src/hooks/useTrusteePositionDocuments.ts +++ b/src/hooks/useTrusteePositionDocuments.ts @@ -163,7 +163,7 @@ export function useTrusteePositionDocuments() { if (attr.readonly === true || attr.editable === false) { return false; } - const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteePositions.ts b/src/hooks/useTrusteePositions.ts index d73a700..c2a51c2 100644 --- a/src/hooks/useTrusteePositions.ts +++ b/src/hooks/useTrusteePositions.ts @@ -178,7 +178,7 @@ export function useTrusteePositions() { if (attr.readonly === true || attr.editable === false) { return false; } - const nonEditableFields = ['id', 'mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['id', 'mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useTrusteeRoles.ts b/src/hooks/useTrusteeRoles.ts index 4e9e617..5be6605 100644 --- a/src/hooks/useTrusteeRoles.ts +++ b/src/hooks/useTrusteeRoles.ts @@ -176,7 +176,7 @@ export function useTrusteeRoles() { if (attr.readonly === true || attr.editable === false) { return false; } - const nonEditableFields = ['mandate', '_createdBy', '_modifiedBy', '_createdAt', '_modifiedAt']; + const nonEditableFields = ['mandate', 'sysCreatedBy', 'sysModifiedBy', 'sysCreatedAt', 'sysModifiedAt']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts index 49f1a44..130c285 100644 --- a/src/hooks/useUsers.ts +++ b/src/hooks/useUsers.ts @@ -412,7 +412,7 @@ export function useOrgUsers() { return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { @@ -560,7 +560,7 @@ export function useOrgUsers() { return false; } // Filter out ID fields and other auto-generated fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete', 'authenticationAuthority']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete', 'authenticationAuthority']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/hooks/useWorkflows.ts b/src/hooks/useWorkflows.ts index 512ee41..9a45caf 100644 --- a/src/hooks/useWorkflows.ts +++ b/src/hooks/useWorkflows.ts @@ -224,7 +224,7 @@ export function useUserWorkflows(options?: { instanceId?: string; featureCode?: return false; // Don't show readonly fields in edit form } // Also filter out common non-editable fields - const nonEditableFields = ['id', 'mandateId', '_createdBy', '_hideDelete']; + const nonEditableFields = ['id', 'mandateId', 'sysCreatedBy', '_hideDelete']; return !nonEditableFields.includes(attr.name); }) .map(attr => { diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 0a027b8..bcef411 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -204,7 +204,7 @@ export const ConnectionsPage: React.FC = () => { // Form attributes for edit modal const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'userId', '_createdBy', '_createdAt', '_modifiedAt', 'connectedAt', 'lastChecked']; + const excludedFields = ['id', 'mandateId', 'userId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'connectedAt', 'lastChecked']; return (attributes || []) .filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index 57eb64e..baa530e 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -142,7 +142,7 @@ export const FilesPage: React.FC = () => { })); cols.push({ - key: '_createdBy', + key: 'sysCreatedBy', label: 'Created By', type: 'text' as any, sortable: true, @@ -289,7 +289,7 @@ export const FilesPage: React.FC = () => { }, [selectedFolderId, _tableRefetch]); const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'fileHash', '_createdBy', '_createdAt', '_modifiedAt', 'creationDate', 'source']; + const excludedFields = ['id', 'mandateId', 'fileHash', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'creationDate', 'source']; return (attributes || []).filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); diff --git a/src/pages/basedata/PromptsPage.tsx b/src/pages/basedata/PromptsPage.tsx index f4835e9..edeca0c 100644 --- a/src/pages/basedata/PromptsPage.tsx +++ b/src/pages/basedata/PromptsPage.tsx @@ -53,7 +53,7 @@ export const PromptsPage: React.FC = () => { // Generate columns from attributes - exclude ID fields from display const columns = useMemo(() => { // Fields to hide in table view - const hiddenColumns = ['id', 'mandateId', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions']; + const hiddenColumns = ['id', 'mandateId', 'sysCreatedAt', 'sysModifiedAt', '_hideDelete', '_permissions']; const cols = (attributes || []) .filter(attr => !hiddenColumns.includes(attr.name)) @@ -71,9 +71,9 @@ export const PromptsPage: React.FC = () => { fkDisplayField: (attr as any).fkDisplayField, })); - // Add _createdBy column with FK resolution to show username + // Add sysCreatedBy column with FK resolution to show username cols.push({ - key: '_createdBy', + key: 'sysCreatedBy', label: 'Created By', type: 'text' as any, sortable: true, @@ -148,7 +148,7 @@ export const PromptsPage: React.FC = () => { // Form attributes for create/edit modal const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'isSystem', '_createdBy', '_createdAt', '_modifiedAt', '_hideDelete', '_permissions']; + const excludedFields = ['id', 'mandateId', 'isSystem', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', '_hideDelete', '_permissions']; return (attributes || []) .filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); diff --git a/src/pages/views/automation/AutomationDefinitionsView.tsx b/src/pages/views/automation/AutomationDefinitionsView.tsx index de2a064..ea5fd93 100644 --- a/src/pages/views/automation/AutomationDefinitionsView.tsx +++ b/src/pages/views/automation/AutomationDefinitionsView.tsx @@ -110,9 +110,9 @@ export const AutomationDefinitionsView: React.FC = () => { const columns = useMemo(() => { const hiddenColumns = [ - 'id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt', + 'id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'template', 'executionLogs', 'placeholders', - '_createdByUserName', 'mandateName', 'featureInstanceName', + 'sysCreatedByUserName', 'mandateName', 'featureInstanceName', ]; const attrColumns = (attributes || []) .filter(attr => !hiddenColumns.includes(attr.name)) @@ -130,7 +130,7 @@ export const AutomationDefinitionsView: React.FC = () => { const enrichedColumns = [ { key: 'mandateName', label: 'Mandant', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 }, { key: 'featureInstanceName', label: 'Feature-Instanz', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 160, minWidth: 100, maxWidth: 250 }, - { key: '_createdByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 }, + { key: 'sysCreatedByUserName', label: 'Erstellt von', type: 'text' as any, sortable: true, filterable: true, searchable: true, width: 150, minWidth: 100, maxWidth: 250 }, ]; return [...attrColumns, ...enrichedColumns]; }, [attributes]); diff --git a/src/pages/views/automation/AutomationTemplatesView.tsx b/src/pages/views/automation/AutomationTemplatesView.tsx index 849a83f..a0bae0c 100644 --- a/src/pages/views/automation/AutomationTemplatesView.tsx +++ b/src/pages/views/automation/AutomationTemplatesView.tsx @@ -52,7 +52,7 @@ export const AutomationTemplatesView: React.FC = () => { value ? System : Instanz }, - { key: '_createdByUserName', label: 'Erstellt von', type: 'string' as const, width: 150 }, + { key: 'sysCreatedByUserName', label: 'Erstellt von', type: 'string' as const, width: 150 }, ], []); const handleEditClick = async (template: AutomationTemplate) => { diff --git a/src/pages/views/realestate/RealEstateParcelsView.tsx b/src/pages/views/realestate/RealEstateParcelsView.tsx index 939af4f..d93c472 100644 --- a/src/pages/views/realestate/RealEstateParcelsView.tsx +++ b/src/pages/views/realestate/RealEstateParcelsView.tsx @@ -110,7 +110,7 @@ export const RealEstateParcelsView: React.FC = () => { }; const formAttributes = useMemo(() => { - const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + const excluded = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; return (attributes || []).filter(attr => !excluded.includes(attr.name)); }, [attributes]); diff --git a/src/pages/views/realestate/RealEstateProjectsView.tsx b/src/pages/views/realestate/RealEstateProjectsView.tsx index 63ac061..25085e2 100644 --- a/src/pages/views/realestate/RealEstateProjectsView.tsx +++ b/src/pages/views/realestate/RealEstateProjectsView.tsx @@ -106,7 +106,7 @@ export const RealEstateProjectsView: React.FC = () => { }; const formAttributes = useMemo(() => { - const excluded = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + const excluded = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; return (attributes || []).filter(attr => !excluded.includes(attr.name)); }, [attributes]); diff --git a/src/pages/views/trustee/TrusteeDocumentsView.tsx b/src/pages/views/trustee/TrusteeDocumentsView.tsx index 6cac4db..7def6c7 100644 --- a/src/pages/views/trustee/TrusteeDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteeDocumentsView.tsx @@ -150,7 +150,7 @@ export const TrusteeDocumentsView: React.FC = () => { // Form attributes (exclude system fields) const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + const excludedFields = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; return (attributes || []).filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); diff --git a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx index b514d48..5aaecfa 100644 --- a/src/pages/views/trustee/TrusteePositionDocumentsView.tsx +++ b/src/pages/views/trustee/TrusteePositionDocumentsView.tsx @@ -52,7 +52,7 @@ export const TrusteePositionDocumentsView: React.FC = () => { if (!attributes || attributes.length === 0) return []; // Exclude system fields from table columns - const excludedFields = ['id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; return attributes .filter((attr: any) => !excludedFields.includes(attr.name)) @@ -127,7 +127,7 @@ export const TrusteePositionDocumentsView: React.FC = () => { // Form attributes (exclude system fields) const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'featureInstanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; return (attributes || []).filter((attr: any) => !excludedFields.includes(attr.name)); }, [attributes]); diff --git a/src/pages/views/trustee/TrusteePositionsView.tsx b/src/pages/views/trustee/TrusteePositionsView.tsx index 7cf9e43..e60b9b6 100644 --- a/src/pages/views/trustee/TrusteePositionsView.tsx +++ b/src/pages/views/trustee/TrusteePositionsView.tsx @@ -257,7 +257,7 @@ export const TrusteePositionsView: React.FC = () => { const positionColumnOrder = [ '_documentRefs', // Belege (download icons) '_syncStatus', // Sync-Status - '_createdAt', // Erstellt am + 'sysCreatedAt', // Erstellt am 'valuta', // Valuta date 'tags', 'company', @@ -372,7 +372,7 @@ export const TrusteePositionsView: React.FC = () => { // Form attributes (exclude system fields) const formAttributes = useMemo(() => { - const excludedFields = ['id', 'mandateId', 'instanceId', '_createdBy', '_createdAt', '_modifiedAt', '_modifiedBy']; + const excludedFields = ['id', 'mandateId', 'instanceId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'sysModifiedBy']; return (attributes || []).filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); diff --git a/src/pages/views/workspace/NeutralizationPanel.tsx b/src/pages/views/workspace/NeutralizationPanel.tsx index a13d52d..bcb0231 100644 --- a/src/pages/views/workspace/NeutralizationPanel.tsx +++ b/src/pages/views/workspace/NeutralizationPanel.tsx @@ -60,7 +60,7 @@ const NeutralizationPanel: React.FC = ({ instanceId }) patternType: m.patternType || 'unknown', fileId: m.fileId, fileName: m.fileName, - createdAt: m.createdAt || m._createdAt, + createdAt: m.createdAt || m.sysCreatedAt, }))); } catch (err) { console.error('Failed to load mappings:', err); diff --git a/src/types/mandate.ts b/src/types/mandate.ts index 0d695b7..5430a3a 100644 --- a/src/types/mandate.ts +++ b/src/types/mandate.ts @@ -322,14 +322,14 @@ export function hasAccess(level: AccessLevel): boolean { */ export function canAccessRecord( level: AccessLevel, - record: { _createdBy?: string }, + record: { sysCreatedBy?: string }, userId: string ): boolean { switch (level) { case 'n': return false; case 'm': - return record._createdBy === userId; + return record.sysCreatedBy === userId; case 'g': case 'a': return true; From 4e2510c38b6e9e2c4f03de6807ed9ef5288ada66 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 28 Mar 2026 21:46:54 +0100 Subject: [PATCH 05/11] fixed sys attributes --- src/components/UnifiedDataBar/ChatsTab.tsx | 10 +++++++++- src/components/UnifiedDataBar/FilesTab.tsx | 8 ++++++-- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/components/UnifiedDataBar/ChatsTab.tsx b/src/components/UnifiedDataBar/ChatsTab.tsx index cfb961e..3260b61 100644 --- a/src/components/UnifiedDataBar/ChatsTab.tsx +++ b/src/components/UnifiedDataBar/ChatsTab.tsx @@ -75,7 +75,15 @@ const ChatsTab: React.FC = ({ `/api/workspace/${context.instanceId}/workflows`, { params: { includeArchived: true } }, ); - const workflows = response.data?.workflows || response.data?.data || []; + const body = response.data ?? {}; + const nested = body.data && typeof body.data === 'object' && !Array.isArray(body.data) + ? (body.data as { workflows?: unknown }) + : null; + const workflowsRaw = + body.workflows ?? + nested?.workflows ?? + (Array.isArray(body.data) ? body.data : null); + const workflows = Array.isArray(workflowsRaw) ? workflowsRaw : []; const groupMap = new Map(); for (const wf of workflows) { diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx index c96ebdd..a801560 100644 --- a/src/components/UnifiedDataBar/FilesTab.tsx +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -51,9 +51,13 @@ const FilesTab: React.FC = ({ context, onFileSelect }) => { setLoading(true); try { const response = await api.get(`/api/workspace/${context.instanceId}/files`); - const data = response.data?.data || response.data || []; + const body = response.data; + const rawList = + (Array.isArray(body?.files) && body.files) || + (Array.isArray(body?.data) && body.data) || + (Array.isArray(body) ? body : []); setFiles( - data.map((f: any) => ({ + rawList.map((f: any) => ({ id: f.id, fileName: f.fileName || f.name || 'unknown', mimeType: f.mimeType, From e9f7b2016f9d167862b1992c8cb41b81624931e1 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 28 Mar 2026 22:29:18 +0100 Subject: [PATCH 06/11] fixes commcoach --- src/components/UnifiedDataBar/FilesTab.tsx | 8 ++++++++ src/pages/Settings.tsx | 11 +++++++++-- src/pages/views/commcoach/CommcoachDossierView.tsx | 9 +++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/components/UnifiedDataBar/FilesTab.tsx b/src/components/UnifiedDataBar/FilesTab.tsx index a801560..e38c991 100644 --- a/src/components/UnifiedDataBar/FilesTab.tsx +++ b/src/components/UnifiedDataBar/FilesTab.tsx @@ -79,6 +79,14 @@ const FilesTab: React.FC = ({ context, onFileSelect }) => { _loadFiles(); }, [_loadFiles]); + useEffect(() => { + const _onFileUploaded = () => { + setTimeout(() => _loadFiles(), 150); + }; + window.addEventListener('fileUploaded', _onFileUploaded as EventListener); + return () => window.removeEventListener('fileUploaded', _onFileUploaded as EventListener); + }, [_loadFiles]); + const _folderNodes = useMemo(() => folders.map(f => ({ id: f.id, diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 5b4cf08..94f73ce 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -360,8 +360,10 @@ const NeutralizationMappingsTab: React.FC = () => {

Platzhalter-Mappings

- Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt. - Hier sehen Sie Ihre gespeicherten Mappings und koennen sie loeschen. + Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt, bevor Text an KI-Modelle + geht; die Antwort wird anschliessend wieder mit Ihren Originalbegriffen angereichert (zentrale Pipeline ueber + den AI-Service). Diese Liste betrifft nur Ihre gespeicherten Platzhalter-Zuordnungen — hier einsehbar und + loeschbar.

{mappings.length === 0 ? ( @@ -537,6 +539,11 @@ export const SettingsPage: React.FC = () => { {activeTab === 'privacy' && (

Datenschutz

+

+ Feature-Daten (z. B. Workspace, CommCoach, Automation) liegen mandantenbezogen; Zugriff richtet sich + nach Ihren Rollen im Mandanten und an Feature-Instanzen. Allgemeine Rechte (Auskunft, Export, + Loeschung) finden Sie unter GDPR. +

Datenexport, Portabilitaet und Kontoloeschung.

GDPR oeffnen
diff --git a/src/pages/views/commcoach/CommcoachDossierView.tsx b/src/pages/views/commcoach/CommcoachDossierView.tsx index ecca02a..3582bf5 100644 --- a/src/pages/views/commcoach/CommcoachDossierView.tsx +++ b/src/pages/views/commcoach/CommcoachDossierView.tsx @@ -111,6 +111,15 @@ export const CommcoachDossierView: React.FC = () => { } }, [activeTab, coach.session?.id, voice]); + useEffect(() => { + coach.onDocumentCreatedRef.current = () => { + window.dispatchEvent(new CustomEvent('fileUploaded', { detail: { source: 'commcoachDocument' } })); + }; + return () => { + coach.onDocumentCreatedRef.current = null; + }; + }, [coach]); + const handleStopTts = useCallback(() => coach.stopTts(), [coach]); const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]); const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]); From d5bb10268416b013e45b0071b59bc3ec6c32b3ed Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 28 Mar 2026 23:54:17 +0100 Subject: [PATCH 07/11] cleaned mandate and unified mandate to be standard type --- src/api/billingApi.ts | 9 - src/api/storeApi.ts | 1 - .../Navigation/MandateNavigation.tsx | 20 ++- src/components/OnboardingWizard.tsx | 62 ++++--- .../UnifiedDataBar/ChatsTab.module.css | 40 ++++- src/components/UnifiedDataBar/ChatsTab.tsx | 120 +++++++++---- src/hooks/useBilling.ts | 17 +- src/hooks/usePrompt.tsx | 161 ++++++++++++++++++ src/pages/admin/AdminMandatesPage.tsx | 17 +- src/pages/billing/Billing.module.css | 2 +- src/pages/billing/BillingAdmin.tsx | 79 +-------- src/pages/billing/BillingDashboard.tsx | 6 - src/pages/billing/BillingDataView.tsx | 119 ++----------- src/pages/billing/BillingMandateView.tsx | 11 +- .../views/commcoach/useVoiceController.ts | 38 ++++- src/pages/views/workspace/WorkspaceInput.tsx | 12 ++ src/pages/views/workspace/WorkspacePage.tsx | 24 ++- src/pages/views/workspace/useWorkspace.ts | 7 +- src/utils/mandateBillingFormMerge.ts | 54 +----- 19 files changed, 435 insertions(+), 364 deletions(-) create mode 100644 src/hooks/usePrompt.tsx diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 403ba89..3a99597 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -4,14 +4,12 @@ import { ApiRequestOptions } from '../hooks/useApi'; // TYPES & INTERFACES // ============================================================================ -export type BillingModel = 'PREPAY_MANDATE' | 'PREPAY_USER'; export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT'; export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM'; export interface BillingBalance { mandateId: string; mandateName: string; - billingModel: BillingModel; balance: number; currency: string; warningThreshold: number; @@ -41,16 +39,12 @@ export interface BillingTransaction { export interface BillingSettings { id: string; mandateId: string; - billingModel: BillingModel; - defaultUserCredit: number; warningThresholdPercent: number; notifyOnWarning: boolean; notifyEmails: string[]; } export interface BillingSettingsUpdate { - billingModel?: BillingModel; - defaultUserCredit?: number; warningThresholdPercent?: number; notifyOnWarning?: boolean; notifyEmails?: string[]; @@ -69,7 +63,6 @@ export interface AccountSummary { id: string; mandateId: string; userId?: string; - accountType: string; balance: number; warningThreshold: number; enabled: boolean; @@ -305,10 +298,8 @@ export async function fetchUsersForMandateAdmin( export interface MandateBalance { mandateId: string; mandateName: string; - billingModel: BillingModel; totalBalance: number; userCount: number; - defaultUserCredit: number; warningThresholdPercent: number; } diff --git a/src/api/storeApi.ts b/src/api/storeApi.ts index 6f728fd..deb8f1e 100644 --- a/src/api/storeApi.ts +++ b/src/api/storeApi.ts @@ -42,7 +42,6 @@ export interface UserMandate { id: string; name: string; label: string; - mandateType: string; } export interface SubscriptionInfo { diff --git a/src/components/Navigation/MandateNavigation.tsx b/src/components/Navigation/MandateNavigation.tsx index 9f9c1dc..616a942 100644 --- a/src/components/Navigation/MandateNavigation.tsx +++ b/src/components/Navigation/MandateNavigation.tsx @@ -33,6 +33,8 @@ import type { import { getPageIcon } from '../../config/pageRegistry'; import { FaSpinner, FaPen } from 'react-icons/fa'; import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation'; +import { usePrompt } from '../../hooks/usePrompt'; +import { useToast } from '../../contexts/ToastContext'; import api from '../../api'; import styles from './MandateNavigation.module.css'; @@ -192,14 +194,19 @@ const EmptyState: React.FC = () => ( export const MandateNavigation: React.FC = () => { const { blocks, loading, refresh } = useNavigation('de'); + const { prompt, PromptDialog } = usePrompt(); + const { showWarning } = useToast(); - const _handleRename = useCallback((instanceId: string, currentLabel: string) => { - const newLabel = window.prompt('Neuer Name:', currentLabel); + const _handleRename = useCallback(async (instanceId: string, currentLabel: string) => { + const newLabel = await prompt('Neuer Name:', { title: 'Umbenennen', defaultValue: currentLabel }); if (!newLabel || newLabel.trim() === currentLabel) return; - api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() }) - .then(() => refresh()) - .catch((err: any) => alert('Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message))); - }, [refresh]); + try { + await api.patch(`/api/features/instances/${instanceId}/rename`, { label: newLabel.trim() }); + refresh(); + } catch (err: any) { + showWarning('Fehler', 'Umbenennung fehlgeschlagen: ' + (err?.response?.data?.detail || err.message)); + } + }, [refresh, prompt, showWarning]); const navigationItems: TreeItem[] = useMemo(() => { const items: TreeItem[] = []; @@ -280,6 +287,7 @@ export const MandateNavigation: React.FC = () => { ) : ( )} +
); }; diff --git a/src/components/OnboardingWizard.tsx b/src/components/OnboardingWizard.tsx index 5815cbb..a1e9fa4 100644 --- a/src/components/OnboardingWizard.tsx +++ b/src/components/OnboardingWizard.tsx @@ -7,7 +7,7 @@ interface OnboardingWizardProps { } const OnboardingWizard: React.FC = ({ onComplete, onDismiss }) => { - const [mandateType, setMandateType] = useState<'personal' | 'company'>('personal'); + const [planKey, setPlanKey] = useState<'TRIAL_7D' | 'STANDARD_MONTHLY'>('TRIAL_7D'); const [companyName, setCompanyName] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -17,8 +17,8 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi setError(null); try { await api.post('/api/local/onboarding', { - mandateType, - companyName: mandateType === 'company' ? companyName : undefined, + planKey, + companyName: companyName.trim() || undefined, }); onComplete(); } catch (err: any) { @@ -40,58 +40,56 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi }}>

Willkommen bei PowerOn

- Wie möchtest du PowerOn nutzen? + Wähle dein Abo und leg los.

- {mandateType === 'company' && ( -
- - setCompanyName(e.target.value)} - placeholder="Name des Unternehmens" - style={{ - width: '100%', padding: '10px 12px', borderRadius: '6px', - border: '1px solid var(--border, #d1d5db)', fontSize: '1rem', - boxSizing: 'border-box', - }} - /> -
- )} +
+ + setCompanyName(e.target.value)} + placeholder="z. B. Firmenname oder Projektname" + style={{ + width: '100%', padding: '10px 12px', borderRadius: '6px', + border: '1px solid var(--border, #d1d5db)', fontSize: '1rem', + boxSizing: 'border-box', + }} + /> +
{error &&

{error}

} @@ -102,7 +100,7 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi }}> Später -
) : (
- {_filteredGroups.map((group) => ( -
-
_toggleGroup(group.featureInstanceId)} - > - - {expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'} - - {group.featureLabel} - {group.chats.length} -
- {expandedGroups.has(group.featureInstanceId) && ( -
- {group.chats.map((chat) => - _renderChatItem(chat, group.featureInstanceId), - )} + {(() => { + const byFeatureCode = new Map(); + for (const g of _filteredGroups) { + const code = g.featureCode || 'workspace'; + if (!byFeatureCode.has(code)) byFeatureCode.set(code, []); + byFeatureCode.get(code)!.push(g); + } + return Array.from(byFeatureCode.entries()).map(([code, instances]) => ( +
+
_toggleGroup(`section:${code}`)} + > + + {expandedGroups.has(`section:${code}`) ? '\u25BC' : '\u25B6'} + + + {_featureCodeLabel(code)} + + + {instances.reduce((n, g) => n + g.chats.length, 0)} +
- )} -
- ))} + {expandedGroups.has(`section:${code}`) && instances.map((group) => ( +
+ {instances.length > 1 && ( +
_toggleGroup(group.featureInstanceId)} + > + + {expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'} + + {group.featureLabel} + {group.chats.length} +
+ )} + {(instances.length === 1 || expandedGroups.has(group.featureInstanceId)) && ( +
+ {group.chats.map((chat) => + _renderChatItem(chat, group.featureInstanceId), + )} +
+ )} +
+ ))} +
+ )); + })()}
)} diff --git a/src/hooks/useBilling.ts b/src/hooks/useBilling.ts index f2c4314..785a519 100644 --- a/src/hooks/useBilling.ts +++ b/src/hooks/useBilling.ts @@ -43,7 +43,7 @@ export type { MandateUserSummary, }; -export type { BillingModel, TransactionType, ReferenceType } from '../api/billingApi'; +export type { TransactionType, ReferenceType } from '../api/billingApi'; /** * Hook for user billing operations @@ -217,34 +217,21 @@ export function useBillingAdmin(mandateId?: string) { } }, [request, mandateId]); - // Update settings — after billing model change, reload dependent data (accounts / users / tx) const saveSettings = useCallback( async (settingsUpdate: BillingSettingsUpdate, targetMandateId?: string) => { const mId = targetMandateId || mandateId; if (!mId) return null; - const previousModel = settings?.billingModel; - try { const data = await updateSettingsAdmin(request, mId, settingsUpdate); setSettings(data); - const newModel = settingsUpdate.billingModel; - const modelChanged = - newModel !== undefined && newModel !== null && newModel !== previousModel; - if (modelChanged) { - await Promise.all([ - loadAccounts(mId), - loadTransactions(mId, 100), - loadUsers(mId), - ]); - } return data; } catch (err) { console.error('Error saving billing settings:', err); throw err; } }, - [request, mandateId, settings?.billingModel, loadAccounts, loadTransactions, loadUsers] + [request, mandateId] ); // Add credit (manual, admin) diff --git a/src/hooks/usePrompt.tsx b/src/hooks/usePrompt.tsx new file mode 100644 index 0000000..b3117a2 --- /dev/null +++ b/src/hooks/usePrompt.tsx @@ -0,0 +1,161 @@ +/** + * usePrompt — application-level prompt dialog replacing native browser prompt(). + * + * Usage: + * const { prompt, PromptDialog } = usePrompt(); + * const value = await prompt('Bitte Namen eingeben:', { title: 'Umbenennen' }); + * if (value !== null) { ... } + * // Render once in the component tree. + */ + +import React, { useState, useCallback, useRef, useEffect } from 'react'; + +export interface PromptOptions { + title?: string; + confirmLabel?: string; + cancelLabel?: string; + placeholder?: string; + defaultValue?: string; + variant?: 'primary' | 'danger'; +} + +interface PromptState { + message: string; + options: Required; + resolve: (value: string | null) => void; +} + +const _defaults: Required = { + title: 'Eingabe', + confirmLabel: 'OK', + cancelLabel: 'Abbrechen', + placeholder: '', + defaultValue: '', + variant: 'primary', +}; + +export function usePrompt() { + const [state, setState] = useState(null); + const resolveRef = useRef<((v: string | null) => void) | null>(null); + const inputRef = useRef(null); + + const prompt = useCallback((message: string, options?: PromptOptions): Promise => { + return new Promise((resolve) => { + resolveRef.current = resolve; + setState({ + message, + options: { ..._defaults, ...options }, + resolve, + }); + }); + }, []); + + const _handleConfirm = useCallback(() => { + const val = inputRef.current?.value ?? ''; + resolveRef.current?.(val); + resolveRef.current = null; + setState(null); + }, []); + + const _handleCancel = useCallback(() => { + resolveRef.current?.(null); + resolveRef.current = null; + setState(null); + }, []); + + const PromptDialog: React.FC = useCallback(() => { + if (!state) return null; + + const { message, options } = state; + const isDanger = options.variant === 'danger'; + + return ( +
+
e.stopPropagation()} + style={{ + background: 'var(--surface-color, #1a1a2e)', + border: '1px solid var(--color-border, #333)', + borderRadius: '12px', + padding: '1.5rem', + minWidth: 360, maxWidth: 500, + boxShadow: '0 8px 32px rgba(0,0,0,0.4)', + display: 'flex', flexDirection: 'column', gap: '1.25rem', + }} + > +

+ {options.title} +

+ +

+ {message} +

+ + { + if (e.key === 'Enter') _handleConfirm(); + if (e.key === 'Escape') _handleCancel(); + }} + style={{ + padding: '10px 14px', + borderRadius: '8px', + border: '1px solid var(--color-border, #444)', + background: 'var(--input-bg, #0d0d1a)', + color: 'var(--text-primary, #e0e0e0)', + fontSize: '0.9rem', + outline: 'none', + width: '100%', + boxSizing: 'border-box', + }} + /> + +
+ + +
+
+
+ ); + }, [state, _handleConfirm, _handleCancel]); + + return { prompt, PromptDialog }; +} diff --git a/src/pages/admin/AdminMandatesPage.tsx b/src/pages/admin/AdminMandatesPage.tsx index f17c49f..cac150a 100644 --- a/src/pages/admin/AdminMandatesPage.tsx +++ b/src/pages/admin/AdminMandatesPage.tsx @@ -14,6 +14,7 @@ import { splitMandateAndBillingFromForm, } from '../../utils/mandateBillingFormMerge'; import { useToast } from '../../contexts/ToastContext'; +import { usePrompt } from '../../hooks/usePrompt'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FaPlus, FaSync, FaBuilding, FaUsers, FaLock } from 'react-icons/fa'; @@ -23,6 +24,7 @@ export const AdminMandatesPage: React.FC = () => { const navigate = useNavigate(); const { request } = useApiRequest(); const { showWarning, showSuccess } = useToast(); + const { prompt, PromptDialog } = usePrompt(); const { mandates, columns, @@ -111,11 +113,18 @@ export const AdminMandatesPage: React.FC = () => { setEditingBillingWarning(null); }; - // Handle delete (confirmation handled by DeleteActionButton) - // System mandates (isSystem=true) are protected from deletion const handleDeleteMandate = async (mandate: Mandate) => { if (mandate.isSystem) { - return; // Safety guard - should not be reachable due to disabled button + return; + } + const entered = await prompt( + `Um den Mandanten "${mandate.name}" unwiderruflich zu löschen, geben Sie den Namen ein:`, + { title: 'Mandant löschen', confirmLabel: 'Löschen', variant: 'danger', placeholder: mandate.name }, + ); + if (entered === null) return; + if (entered !== mandate.name) { + showWarning('Löschung abgebrochen', 'Der eingegebene Name stimmt nicht überein.'); + return; } await handleDelete(mandate.id); }; @@ -267,6 +276,8 @@ export const AdminMandatesPage: React.FC = () => {
)} + + {/* Edit Modal */} {editingFormData && (
= ({ settings, onSave, loading }) => { const [formData, setFormData] = useState({ - billingModel: (settings?.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE') as BillingSettings['billingModel'], - defaultUserCredit: Number(settings?.defaultUserCredit ?? 0), warningThresholdPercent: Number(settings?.warningThresholdPercent ?? 10), notifyOnWarning: settings?.notifyOnWarning ?? true, }); @@ -96,8 +94,6 @@ const SettingsEditor: React.FC = ({ settings, onSave, loadi useEffect(() => { if (settings) { setFormData({ - billingModel: settings.billingModel === 'PREPAY_USER' ? 'PREPAY_USER' : 'PREPAY_MANDATE', - defaultUserCredit: Number(settings.defaultUserCredit ?? 0), warningThresholdPercent: Number(settings.warningThresholdPercent ?? 10), notifyOnWarning: settings.notifyOnWarning ?? true, }); @@ -130,32 +126,6 @@ const SettingsEditor: React.FC = ({ settings, onSave, loadi )}
-
-
- - -
- -
- - setFormData(prev => ({ ...prev, defaultUserCredit: Number(e.target.value) }))} - min="0" - step="0.01" - /> -
-
-
@@ -202,28 +172,15 @@ const SettingsEditor: React.FC = ({ settings, onSave, loadi // ============================================================================ interface CreditAdderProps { - settings: BillingSettings | null; - accounts: AccountSummary[]; - users: MandateUserSummary[]; onAddCredit: (userId: string | undefined, amount: number, description: string) => Promise; } -const CreditAdder: React.FC = ({ settings, accounts, users, onAddCredit }) => { - const [selectedUserId, setSelectedUserId] = useState(''); +const CreditAdder: React.FC = ({ onAddCredit }) => { const [amount, setAmount] = useState(''); const [description, setDescription] = useState('Manuelle Buchung durch Admin'); const [saving, setSaving] = useState(false); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); - const isPrepayUser = settings?.billingModel === 'PREPAY_USER'; - - const accountsByUserId = accounts - .filter(acc => acc.accountType === 'USER') - .reduce((map, acc) => { - if (acc.userId) map[acc.userId] = acc; - return map; - }, {} as Record); - const _handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); const numAmount = parseFloat(amount); @@ -236,7 +193,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on setMessage(null); try { - await onAddCredit(isPrepayUser ? selectedUserId : undefined, numAmount, description); + await onAddCredit(undefined, numAmount, description); const label = numAmount > 0 ? `${_formatCurrency(numAmount)} erfolgreich gutgeschrieben.` : `${_formatCurrency(Math.abs(numAmount))} erfolgreich abgezogen.`; @@ -260,31 +217,6 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on )} - {isPrepayUser && ( -
-
- - -
-
- )} -
@@ -313,7 +245,7 @@ const CreditAdder: React.FC = ({ settings, accounts, users, on @@ -367,7 +299,7 @@ const AccountsOverview: React.FC = ({ accounts, users, lo
{accounts.map((account) => (
-

{account.accountType === 'MANDATE' ? 'Mandanten-Konto' : 'Benutzer-Konto'}

+

{!account.userId ? 'Mandanten-Konto' : 'Benutzer-Konto'}

{account.userId && User: {_userNameMap.get(account.userId) || account.userId}} Guthaben: {formatCurrency(account.balance)} @@ -782,9 +714,6 @@ export const BillingAdmin: React.FC = () => { <> {isSysAdmin && ( )} diff --git a/src/pages/billing/BillingDashboard.tsx b/src/pages/billing/BillingDashboard.tsx index b61b536..7525836 100644 --- a/src/pages/billing/BillingDashboard.tsx +++ b/src/pages/billing/BillingDashboard.tsx @@ -26,11 +26,6 @@ const BalanceCard: React.FC = ({ balance, onClick }) => { }).format(amount); }; - const getBillingModelLabel = (model: string) => { - if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)'; - return 'Prepaid (Mandant)'; - }; - return (
= ({ balance, onClick }) => { >

{balance.mandateName}

- {getBillingModelLabel(balance.billingModel)}
{formatCurrency(balance.balance)} diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index feb44f1..5846177 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -13,14 +13,10 @@ import { FormGeneratorTable, ColumnConfig } from '../../components/FormGenerator import { FormGeneratorReport } from '../../components/FormGenerator/FormGeneratorReport'; import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../../components/FormGenerator/FormGeneratorReport'; import api from '../../api'; -import { useApiRequest } from '../../hooks/useApi'; import { useBilling, type BillingBalance } from '../../hooks/useBilling'; -import { createCheckoutSession, UserTransaction } from '../../api/billingApi'; -import { getUserDataCache } from '../../utils/userCache'; +import { UserTransaction } from '../../api/billingApi'; import styles from './Billing.module.css'; -const STRIPE_AMOUNT_PRESETS = [10, 25, 50, 100, 250, 500]; - // ============================================================================ // HELPER: Currency formatter // ============================================================================ @@ -52,28 +48,13 @@ interface ViewStatistics { interface BalanceCardProps { balance: BillingBalance; - onCheckout?: (mandateId: string, amount: number) => void; - checkoutLoading?: boolean; } -const BalanceCard: React.FC = ({ balance, onCheckout, checkoutLoading }) => { - const [selectedAmount, setSelectedAmount] = useState(STRIPE_AMOUNT_PRESETS[0]); - const [showCheckout, setShowCheckout] = useState(false); - - const _getBillingModelLabel = (model: string) => { - if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)'; - return 'Prepaid (Mandant)'; - }; - - // Stripe top-up on this page: only personal prepaid wallets. Mandate pool (PREPAY_MANDATE) is topped up by mandate admins via Administration → Billing. - const canStripeTopUpHere = balance.billingModel === 'PREPAY_USER'; - const isMandatePrepaidPool = balance.billingModel === 'PREPAY_MANDATE'; - +const BalanceCard: React.FC = ({ balance }) => { return (

{balance.mandateName}

- {_getBillingModelLabel(balance.billingModel)}
{_formatCurrency(balance.balance)} @@ -83,60 +64,17 @@ const BalanceCard: React.FC = ({ balance, onCheckout, checkout Niedriges Guthaben
)} - {isMandatePrepaidPool && ( -

- Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing). -

- )} - {canStripeTopUpHere && onCheckout && ( -
- {!showCheckout ? ( - - ) : ( -
- - - -
- )} -
- )} +

+ Aufladung des Mandanten-Guthabens ist nur für Mandanten-Administratoren möglich (Menü Administration → Billing). +

); }; @@ -329,8 +267,6 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] { export const BillingDataView: React.FC = () => { const [activeTab, setActiveTab] = useState('overview'); const [searchParams, setSearchParams] = useSearchParams(); - const { request } = useApiRequest(); - const [checkoutLoading, setCheckoutLoading] = useState(false); const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); // Scope filter: 'personal' | 'all' | mandateId @@ -399,31 +335,6 @@ export const BillingDataView: React.FC = () => { setCheckoutMessage(null); }, [searchParams, setSearchParams]); - const _handleCheckout = useCallback(async (mandateId: string, amount: number) => { - setCheckoutLoading(true); - setCheckoutMessage(null); - try { - const currentUser = getUserDataCache(); - const currentUrl = new URL(window.location.href); - currentUrl.searchParams.delete('success'); - currentUrl.searchParams.delete('canceled'); - currentUrl.searchParams.delete('session_id'); - currentUrl.hash = ''; - const returnUrl = `${currentUrl.origin}${currentUrl.pathname}${currentUrl.search}`; - const result = await createCheckoutSession(request, mandateId, { - userId: currentUser?.id, - amount, - returnUrl, - }); - if (result?.redirectUrl) { - window.location.href = result.redirectUrl; - } - } catch (err: any) { - setCheckoutMessage({ type: 'error', text: err.message || 'Fehler beim Erstellen der Checkout-Session' }); - setCheckoutLoading(false); - } - }, [request]); - // All user balances (for admin overview cards) const [allUserBalances, setAllUserBalances] = useState([]); const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false); @@ -666,8 +577,6 @@ export const BillingDataView: React.FC = () => { ))}
@@ -686,7 +595,7 @@ export const BillingDataView: React.FC = () => {

{ub.userName || ub.userId?.slice(0, 8)}

- {ub.mandateName} + {ub.mandateName}
{_formatCurrency(ub.balance || 0)} diff --git a/src/pages/billing/BillingMandateView.tsx b/src/pages/billing/BillingMandateView.tsx index 3279505..b85f149 100644 --- a/src/pages/billing/BillingMandateView.tsx +++ b/src/pages/billing/BillingMandateView.tsx @@ -38,20 +38,14 @@ const MandateBalanceTable: React.FC = ({ }).format(amount); }; - const getBillingModelLabel = (model: string) => { - if (model === 'PREPAY_USER') return 'Prepaid (Benutzer)'; - return 'Prepaid (Mandant)'; - }; - return (
- - + @@ -63,9 +57,8 @@ const MandateBalanceTable: React.FC = ({ className={selectedMandateId === balance.mandateId ? styles.selectedRow : ''} > - - +
MandantBilling-Modell Anzahl BenutzerStandard-GuthabenWarnschwelle (%) Gesamtguthaben Aktion
{balance.mandateName || balance.mandateId}{getBillingModelLabel(balance.billingModel)} {balance.userCount}{formatCurrency(balance.defaultUserCredit)}{balance.warningThresholdPercent}% {formatCurrency(balance.totalBalance)} + ) : ( +

{balance.mandateName}

+ )}
{_formatCurrency(balance.balance)} @@ -267,7 +288,12 @@ function _buildStatisticsSections(viewStats: ViewStatistics): ReportSection[] { export const BillingDataView: React.FC = () => { const [activeTab, setActiveTab] = useState('overview'); const [searchParams, setSearchParams] = useSearchParams(); + const navigate = useNavigate(); const [checkoutMessage, setCheckoutMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); + + const _openMandateBillingAdmin = useCallback((mandateId: string) => { + navigate(`/admin/billing?mandate=${encodeURIComponent(mandateId)}`); + }, [navigate]); // Scope filter: 'personal' | 'all' | mandateId const [selectedScope, setSelectedScope] = useState('personal'); @@ -335,10 +361,6 @@ export const BillingDataView: React.FC = () => { setCheckoutMessage(null); }, [searchParams, setSearchParams]); - // All user balances (for admin overview cards) - const [allUserBalances, setAllUserBalances] = useState([]); - const [allUserBalancesLoading, setAllUserBalancesLoading] = useState(false); - // Statistics state (shared by Overview and Statistics tabs) const [viewStats, setViewStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); @@ -349,19 +371,6 @@ export const BillingDataView: React.FC = () => { const [transactionsError, setTransactionsError] = useState(null); const [transactionsPagination, setTransactionsPagination] = useState(null); - // Load all user balances for admin overview - const _loadAllUserBalances = useCallback(async () => { - try { - setAllUserBalancesLoading(true); - const response = await api.get('/api/billing/view/users/balances'); - setAllUserBalances(Array.isArray(response.data) ? response.data : []); - } catch { - setAllUserBalances([]); - } finally { - setAllUserBalancesLoading(false); - } - }, []); - // Load aggregated statistics from the view/statistics route const _loadViewStatistics = useCallback(async (period: string, year: number, month?: number) => { try { @@ -402,10 +411,7 @@ export const BillingDataView: React.FC = () => { if (activeTab === 'overview' || activeTab === 'statistics') { _loadViewStatistics('month', new Date().getFullYear()); } - if (activeTab === 'overview') { - _loadAllUserBalances(); - } - }, [activeTab, _loadViewStatistics, _loadAllUserBalances, selectedScope]); + }, [activeTab, _loadViewStatistics, selectedScope]); // Load transactions with pagination support const _loadTransactions = useCallback(async (paginationParams?: any) => { @@ -555,12 +561,6 @@ export const BillingDataView: React.FC = () => { const filteredBalances = selectedScope === 'personal' || selectedScope === 'all' ? balances : balances.filter(b => b.mandateId === selectedScope); - - const filteredUserBalances = selectedScope === 'personal' - ? [] // personal view: only own balance cards, no other users - : selectedScope === 'all' - ? allUserBalances - : allUserBalances.filter(ub => ub.mandateId === selectedScope); return ( <> @@ -577,36 +577,13 @@ export const BillingDataView: React.FC = () => { ))}
)} - {/* All User Balance Cards (mandate/all scope) */} - {filteredUserBalances.length > 0 && ( -
-

Benutzer-Guthaben

- {allUserBalancesLoading ? ( -
Lade Benutzer-Guthaben...
- ) : ( -
- {filteredUserBalances.map((ub, idx) => ( -
-
-

{ub.userName || ub.userId?.slice(0, 8)}

- {ub.mandateName} -
-
- {_formatCurrency(ub.balance || 0)} -
-
- ))} -
- )} -
- )} - {/* Usage Statistics via FormGeneratorReport */}
= { NONE: '—', }; +/** Matches backend STORAGE_PRICE_PER_GB_CHF (CHF/GB/month for over-plan storage). */ +const storageOverageChfPerGbMonth = 0.5; + // ============================================================================ // Plan Card // ============================================================================ @@ -98,6 +102,19 @@ const PlanCard: React.FC = ({ plan, isCurrent, onActivate, activa
User: {_formatCurrency(plan.pricePerUserCHF)} / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}
Instanz: {_formatCurrency(plan.pricePerFeatureInstanceCHF)} / {_periodLabel[plan.billingPeriod] || plan.billingPeriod}
+
+ AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode + {' · '} + Speicher (inkl.):{' '} + + {plan.maxDataVolumeMB == null + ? 'unbegrenzt' + : formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)} + +
+
+ Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat +
)} @@ -106,6 +123,14 @@ const PlanCard: React.FC = ({ plan, isCurrent, onActivate, activa {plan.trialDays} Tage kostenlos {plan.maxUsers && <> · max. {plan.maxUsers} User} {plan.maxFeatureInstances && <> · max. {plan.maxFeatureInstances} Instanzen} + {(plan.maxDataVolumeMB != null || (plan.budgetAiCHF ?? 0) > 0) && ( + <> + {plan.maxDataVolumeMB != null && ( + <> · Speicher inkl. {formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)} + )} + {(plan.budgetAiCHF ?? 0) > 0 && <> · AI-Budget { _formatCurrency(plan.budgetAiCHF ?? 0)}} + + )} )} @@ -214,6 +239,20 @@ const SubInfoCard: React.FC = ({ sub, plan, label, onCancel, onRea {isActive && !sub.recurring && sub.currentPeriodEnd && ( Läuft aus am: {_formatDate(sub.currentPeriodEnd)} )} + {plan && ( + <> + AI-Budget (inkl.): {_formatCurrency(plan.budgetAiCHF ?? 0)} / Periode + + Speicher (inkl.):{' '} + {plan.maxDataVolumeMB == null + ? 'unbegrenzt' + : formatBinaryDataSizeFromMebibytes(plan.maxDataVolumeMB)} + + + Speicher über Plan: {_formatCurrency(storageOverageChfPerGbMonth)} / GB / Monat (High-Watermark) + + + )} )} @@ -358,7 +397,7 @@ export const SubscriptionTab: React.FC = ({ mandateId }) = { title: isPendingOrScheduled ? 'Vorgang abbrechen' : 'Abonnement kündigen', confirmLabel: isPendingOrScheduled ? 'Ja, abbrechen' : 'Kündigen', - cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : undefined, + cancelLabel: isPendingOrScheduled ? 'Nein, zurück' : 'Abbrechen', variant: 'danger', }, ); diff --git a/src/pages/views/commcoach/useVoiceController.ts b/src/pages/views/commcoach/useVoiceController.ts index c030e9c..5e9e8c8 100644 --- a/src/pages/views/commcoach/useVoiceController.ts +++ b/src/pages/views/commcoach/useVoiceController.ts @@ -6,7 +6,7 @@ * * Uses the generic useVoiceStream hook for mic capture + STT streaming. * Google Streaming STT handles silence detection natively. - * STT language is loaded from central voice preferences (/api/local/voice-preferences). + * STT language is loaded from central voice preferences (/api/voice/preferences). */ import { useState, useRef, useCallback, useEffect } from 'react'; @@ -46,7 +46,7 @@ export function useVoiceController(callbacks: VoiceControllerCallbacks): VoiceCo useEffect(() => { let cancelled = false; - api.get('/api/local/voice-preferences').then((res) => { + api.get('/api/voice/preferences').then((res) => { if (cancelled) return; const lang = res.data?.sttLanguage || res.data?.ttsLanguage; if (lang) sttLanguageRef.current = lang; diff --git a/src/pages/views/workspace/ChatStream.tsx b/src/pages/views/workspace/ChatStream.tsx index 07494d3..a66bc3b 100644 --- a/src/pages/views/workspace/ChatStream.tsx +++ b/src/pages/views/workspace/ChatStream.tsx @@ -9,6 +9,7 @@ import React, { useRef, useEffect, useCallback, useState } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import api from '../../../api'; +import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize'; import type { Message, MessageDocument } from '../../../components/UiComponents/Messages/MessagesTypes'; import type { AgentProgress, FileEditProposal } from './useWorkspace'; @@ -339,11 +340,7 @@ function _FileCard({ doc }: { doc: MessageDocument }) { const ext = (doc.fileName || '').split('.').pop()?.toLowerCase() || ''; const icon = _getFileIcon(ext); - const sizeLabel = doc.fileSize - ? doc.fileSize > 1024 * 1024 - ? `${(doc.fileSize / (1024 * 1024)).toFixed(1)} MB` - : `${(doc.fileSize / 1024).toFixed(1)} KB` - : ''; + const sizeLabel = doc.fileSize ? formatBinaryDataSizeBytes(doc.fileSize) : ''; return (
= ({ instanceId, fileId, fi
{file.mimeType} - {_formatFileSize(file.fileSize)} + {formatBinaryDataSizeBytes(file.fileSize)} {file.status && {file.status}}
{file.description && ( @@ -146,8 +147,3 @@ function _isTextMime(mime: string): boolean { return textTypes.includes(mime); } -function _formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} diff --git a/src/pages/views/workspace/WorkspaceEditorPage.tsx b/src/pages/views/workspace/WorkspaceEditorPage.tsx index 8cb7b5d..7629520 100644 --- a/src/pages/views/workspace/WorkspaceEditorPage.tsx +++ b/src/pages/views/workspace/WorkspaceEditorPage.tsx @@ -15,6 +15,7 @@ import type { editor as monacoEditor } from 'monaco-editor'; import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useWorkspaceEditor, type EditorFileEdit } from './useWorkspaceEditor'; import { FaArrowLeft, FaCheck, FaTimes, FaCheckDouble, FaBan, FaSync } from 'react-icons/fa'; +import { formatBinaryDataSizeBytes } from '../../../utils/formatDataSize'; function _getMonacoLanguage(fileName: string): string { const ext = fileName.split('.').pop()?.toLowerCase() || ''; @@ -27,12 +28,6 @@ function _getMonacoLanguage(fileName: string): string { return langMap[ext] || 'plaintext'; } -function _formatBytes(bytes: number): string { - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -} - export const WorkspaceEditorPage: React.FC = () => { const instanceId = useInstanceId() || ''; const navigate = useNavigate(); @@ -155,8 +150,8 @@ export const WorkspaceEditorPage: React.FC = () => { }}>
{activeEdit.fileName} - Original: {_formatBytes(activeEdit.oldContent.length)} - Geaendert: {_formatBytes(activeEdit.newContent.length)} + Original: {formatBinaryDataSizeBytes(activeEdit.oldContent.length)} + Geaendert: {formatBinaryDataSizeBytes(activeEdit.newContent.length)}
-

{_formatBytes(kpis.indexedBytesTotal)}

+

{formatBinaryDataSizeBytes(kpis.indexedBytesTotal)}

Indexiertes Datenvolumen (geschätzt)

diff --git a/src/utils/formatDataSize.ts b/src/utils/formatDataSize.ts new file mode 100644 index 0000000..f0f6668 --- /dev/null +++ b/src/utils/formatDataSize.ts @@ -0,0 +1,43 @@ +/** + * Central binary (1024) data-size formatting for the UI. + * + * - Use formatBinaryDataSizeBytes for raw byte counts (files, RAG totals, …). + * - Use formatBinaryDataSizeFromMebibytes for API fields stored as MB (mebibytes), e.g. maxDataVolumeMB. + */ + +const BINARY_BASE = 1024; +const BINARY_UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const; + +function _maxFractionDigits(value: number): number { + if (value >= 100 || Number.isInteger(value)) return 0; + if (value >= 10) return 1; + return 2; +} + +/** + * Human-readable size from a byte count; picks B … TB automatically (1024-based). + */ +export function formatBinaryDataSizeBytes(bytes: number, localeId = 'de-CH'): string { + if (!Number.isFinite(bytes)) return '—'; + if (bytes < 0) return '—'; + if (bytes === 0) return `0 ${BINARY_UNITS[0]}`; + + const rawExp = Math.floor(Math.log(bytes) / Math.log(BINARY_BASE)); + const exp = Math.max(0, Math.min(BINARY_UNITS.length - 1, rawExp)); + const value = bytes / BINARY_BASE ** exp; + const maxFrac = _maxFractionDigits(value); + const formatted = new Intl.NumberFormat(localeId, { + maximumFractionDigits: maxFrac, + minimumFractionDigits: 0, + }).format(value); + return `${formatted} ${BINARY_UNITS[exp]}`; +} + +/** + * Same as formatBinaryDataSizeBytes, but input is mebibytes (API convention for plan limits). + */ +export function formatBinaryDataSizeFromMebibytes(mebibytes: number, localeId = 'de-CH'): string { + if (!Number.isFinite(mebibytes) || mebibytes < 0) return '—'; + if (mebibytes === 0) return `0 ${BINARY_UNITS[0]}`; + return formatBinaryDataSizeBytes(mebibytes * BINARY_BASE * BINARY_BASE, localeId); +} From 317e019b180c1d0d09df9baeb07b4e870def7715 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sun, 29 Mar 2026 21:55:13 +0200 Subject: [PATCH 09/11] unified failsafe neutralization architecture --- src/api/billingApi.ts | 2 +- .../Automation2FlowEditor.tsx | 10 +- .../FolderTree/FolderTree.module.css | 13 +- src/components/FolderTree/FolderTree.tsx | 135 ++++++----- src/pages/Settings.tsx | 6 +- src/pages/basedata/FilesPage.tsx | 7 +- src/pages/billing/BillingDataView.tsx | 217 ++++++++++++++++-- .../views/workspace/NeutralizationPanel.tsx | 5 +- 8 files changed, 305 insertions(+), 90 deletions(-) diff --git a/src/api/billingApi.ts b/src/api/billingApi.ts index 326fc25..76f79fa 100644 --- a/src/api/billingApi.ts +++ b/src/api/billingApi.ts @@ -5,7 +5,7 @@ import { ApiRequestOptions } from '../hooks/useApi'; // ============================================================================ export type TransactionType = 'CREDIT' | 'DEBIT' | 'ADJUSTMENT'; -export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE'; +export type ReferenceType = 'WORKFLOW' | 'PAYMENT' | 'ADMIN' | 'SYSTEM' | 'STORAGE' | 'SUBSCRIPTION'; export interface BillingBalance { mandateId: string; diff --git a/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx b/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx index eef7a76..0f93e66 100644 --- a/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx +++ b/src/components/Automation2FlowEditor/Automation2FlowEditor.tsx @@ -27,6 +27,7 @@ import { NodeSidebar } from './NodeSidebar'; import { CanvasHeader } from './CanvasHeader'; import { getCategoryIcon } from './utils'; import { fromApiGraph, toApiGraph } from './graphUtils'; +import { usePrompt } from '../../hooks/usePrompt'; import styles from './Automation2FlowEditor.module.css'; const LOG = '[Automation2]'; @@ -44,6 +45,7 @@ export const Automation2FlowEditor: React.FC = ({ initialWorkflowId, }) => { const { request } = useApiRequest(); + const { prompt: promptInput, PromptDialog } = usePrompt(); const [nodeTypes, setNodeTypes] = useState([]); const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); @@ -115,8 +117,9 @@ export const Automation2FlowEditor: React.FC = ({ await updateWorkflow(request, instanceId, currentWorkflowId, { graph }); setExecuteResult({ success: true } as ExecuteGraphResponse); } else { - const label = prompt('Workflow-Name:', 'Neuer Workflow') || 'Neuer Workflow'; - const created = await createWorkflow(request, instanceId, { label, graph }); + const label = await promptInput('Workflow-Name:', { title: 'Workflow speichern', defaultValue: 'Neuer Workflow', placeholder: 'Name des Workflows' }); + if (!label) { setSaving(false); return; } + const created = await createWorkflow(request, instanceId, { label: label.trim() || 'Neuer Workflow', graph }); setCurrentWorkflowId(created.id); setWorkflows((prev) => [...prev, created]); setExecuteResult({ success: true } as ExecuteGraphResponse); @@ -126,7 +129,7 @@ export const Automation2FlowEditor: React.FC = ({ } finally { setSaving(false); } - }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId]); + }, [request, instanceId, canvasNodes, canvasConnections, currentWorkflowId, promptInput]); const handleLoad = useCallback( async (workflowId: string) => { @@ -321,6 +324,7 @@ export const Automation2FlowEditor: React.FC = ({ )}
+ ); }; diff --git a/src/components/FolderTree/FolderTree.module.css b/src/components/FolderTree/FolderTree.module.css index 5fd26fa..deab4d3 100644 --- a/src/components/FolderTree/FolderTree.module.css +++ b/src/components/FolderTree/FolderTree.module.css @@ -152,10 +152,21 @@ display: flex; gap: 2px; flex-shrink: 0; - margin-left: auto; 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 4332748..d4f92ef 100644 --- a/src/components/FolderTree/FolderTree.tsx +++ b/src/components/FolderTree/FolderTree.tsx @@ -13,6 +13,7 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { FaFolder, FaFolderOpen, FaPlus, FaPen, FaTrash, FaChevronRight, FaGlobe, FaSyncAlt, FaDownload } from 'react-icons/fa'; +import { usePrompt } from '../../hooks/usePrompt'; import styles from './FolderTree.module.css'; /* ── Public types ──────────────────────────────────────────────────────── */ @@ -249,68 +250,70 @@ function _FileItem({ file, sel }: { file: FileNode; sel: SelectionCtx }) { ) : ( {file.fileName} )} - {!renaming && file.fileSize != null && ( - - {(file.fileSize / 1024).toFixed(0)}K - - )} - {!renaming && file.scope != null && ( - - - - - )} {!renaming && ( - - {sel.onRenameFile && !multiSelected && ( - - )} - {multiSelected && isSelected ? ( - <> - {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( - - )} - {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( - - )} - - ) : ( - (sel.onDeleteFile || sel.onDeleteFiles) && ( - - ) + )} + {multiSelected && isSelected ? ( + <> + {sel.selectedFolderIds.length > 0 && sel.onDeleteFolders && ( + + )} + {sel.selectedFileIds.length > 0 && sel.onDeleteFiles && ( + + )} + + ) : ( + (sel.onDeleteFile || sel.onDeleteFiles) && ( + + ) + )} + + {file.fileSize != null && ( + + {(file.fileSize / 1024).toFixed(0)}K + + )} + {file.scope != null && ( + + + + )} )} @@ -328,6 +331,7 @@ interface TreeNodeProps { showFiles: boolean; filesByFolder: Map; sel: SelectionCtx; + promptFolderName: (message: string) => Promise; onToggle: (id: string) => void; onSelect: (id: string | null) => void; onCreateFolder?: (name: string, parentId: string | null) => Promise; @@ -342,6 +346,7 @@ interface TreeNodeProps { function _TreeNode({ node, depth, selectedFolderId, expandedIds, showFiles, filesByFolder, sel, + promptFolderName, onToggle, onSelect, onCreateFolder, onRenameFolder, onDeleteFolder, onMoveFolder, onMoveFolders, onMoveFile, onMoveFiles, onDownloadFolder, @@ -372,12 +377,12 @@ function _TreeNode({ const _handleAdd = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); if (!onCreateFolder) return; - const name = prompt('Neuer Ordnername:'); + const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' }); if (name?.trim()) { await onCreateFolder(name.trim(), node.id); if (!expandedIds.has(node.id)) onToggle(node.id); } - }, [onCreateFolder, node.id, expandedIds, onToggle]); + }, [onCreateFolder, node.id, expandedIds, onToggle, promptFolderName]); const _handleDeleteSingle = useCallback(async (e: React.MouseEvent) => { e.stopPropagation(); @@ -539,6 +544,7 @@ function _TreeNode({ showFiles={showFiles} filesByFolder={filesByFolder} sel={sel} + promptFolderName={promptFolderName} onToggle={onToggle} onSelect={onSelect} onCreateFolder={onCreateFolder} @@ -574,6 +580,7 @@ export default function FolderTree({ const [rootDropOver, setRootDropOver] = useState(false); const [internalSelectedIds, setInternalSelectedIds] = useState>(new Set()); const lastClickedIdRef = useRef(null); + const { prompt: promptFolderName, PromptDialog } = usePrompt(); const expandedIds = externalExpandedIds ?? internalExpandedIds; @@ -753,7 +760,7 @@ export default function FolderTree({ className={styles.actionBtn} onClick={async (e) => { e.stopPropagation(); - const name = prompt('Neuer Ordnername:'); + const name = await promptFolderName('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' }); if (name?.trim()) await onCreateFolder(name.trim(), null); }} title="Neuer Ordner" @@ -774,6 +781,7 @@ export default function FolderTree({ showFiles={showFiles} filesByFolder={filesByFolder} sel={sel} + promptFolderName={promptFolderName} onToggle={_handleToggle} onSelect={onSelect} onCreateFolder={onCreateFolder} @@ -790,6 +798,7 @@ export default function FolderTree({ <_FileItem key={file.id} file={file} sel={sel} /> ))} + ); } diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index ff8af52..84691a2 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -542,11 +542,11 @@ export const SettingsPage: React.FC = () => {

Feature-Daten (z. B. Workspace, CommCoach, Automation) liegen mandantenbezogen; Zugriff richtet sich nach Ihren Rollen im Mandanten und an Feature-Instanzen. Allgemeine Rechte (Auskunft, Export, - Loeschung) finden Sie unter GDPR. + Löschung) finden Sie unter GDPR.

-

Datenexport, Portabilitaet und Kontoloeschung.

-
GDPR oeffnen
+

Datenexport, Portabilität und Kontolöschung.

+
GDPR öffnen
)} diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index baa530e..fe984e9 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -16,6 +16,7 @@ import type { FileNode } from '../../components/FolderTree/FolderTree'; import { useResizablePanels } from '../../hooks/useResizablePanels'; import { FaSync, FaFolder, FaUpload, FaDownload, FaEye, FaFolderPlus } from 'react-icons/fa'; import { useToast } from '../../contexts/ToastContext'; +import { usePrompt } from '../../hooks/usePrompt'; import styles from '../admin/Admin.module.css'; interface UserFile { @@ -31,6 +32,7 @@ interface UserFile { export const FilesPage: React.FC = () => { const fileInputRef = useRef(null); const { showSuccess, showError } = useToast(); + const { prompt: promptInput, PromptDialog } = usePrompt(); const [selectedFolderId, setSelectedFolderId] = useState(null); const { @@ -223,11 +225,11 @@ export const FilesPage: React.FC = () => { }; const _handleNewFolder = useCallback(async () => { - const name = prompt('Neuer Ordnername:'); + const name = await promptInput('Neuer Ordnername:', { title: 'Neuer Ordner', placeholder: 'Ordnername' }); if (name?.trim()) { await handleCreateFolder(name.trim(), selectedFolderId); } - }, [handleCreateFolder, selectedFolderId]); + }, [handleCreateFolder, selectedFolderId, promptInput]); const _onRowDragStart = useCallback((e: React.DragEvent, row: UserFile) => { const isInSelection = selectedFiles.some(f => f.id === row.id); @@ -522,6 +524,7 @@ export const FilesPage: React.FC = () => { )} + ); }; diff --git a/src/pages/billing/BillingDataView.tsx b/src/pages/billing/BillingDataView.tsx index 4cd1bb3..65501a0 100644 --- a/src/pages/billing/BillingDataView.tsx +++ b/src/pages/billing/BillingDataView.tsx @@ -15,6 +15,7 @@ import type { ReportSection, ReportFilterState, ReportChartDataPoint } from '../ import api from '../../api'; import { useBilling, type BillingBalance } from '../../hooks/useBilling'; import { UserTransaction } from '../../api/billingApi'; +import { formatBinaryDataSizeFromMebibytes } from '../../utils/formatDataSize'; import styles from './Billing.module.css'; // ============================================================================ @@ -42,6 +43,17 @@ interface ViewStatistics { timeSeries: Array<{ date: string; cost: number; count: number }>; } +interface DataVolumeInfo { + mandateId: string; + mandateName: string; + usedMB: number; + filesMB: number; + ragIndexMB: number; + maxDataVolumeMB: number | null; + percentUsed: number | null; + warning: boolean; +} + // ============================================================================ // BALANCE CARD COMPONENT // ============================================================================ @@ -364,7 +376,11 @@ export const BillingDataView: React.FC = () => { // Statistics state (shared by Overview and Statistics tabs) const [viewStats, setViewStats] = useState(null); const [statsLoading, setStatsLoading] = useState(false); - + + // Storage volume state (for Statistics tab) + const [storageData, setStorageData] = useState([]); + const [storageLoading, setStorageLoading] = useState(false); + // Transactions state (for Transactions tab) const [transactions, setTransactions] = useState([]); const [transactionsLoading, setTransactionsLoading] = useState(false); @@ -406,12 +422,47 @@ export const BillingDataView: React.FC = () => { _loadViewStatistics(period, year, month); }, [_loadViewStatistics]); - // Initial data load: load statistics when overview or statistics tab becomes active + // Load storage volume for all accessible mandates + const _loadStorageData = useCallback(async () => { + const mandateIds = new Set(); + for (const b of balances) { + if (selectedScope === 'personal' || selectedScope === 'all' || selectedScope === b.mandateId) { + mandateIds.add(b.mandateId); + } + } + if (mandateIds.size === 0) { + setStorageData([]); + return; + } + + setStorageLoading(true); + try { + const mandateNameMap = new Map(balances.map(b => [b.mandateId, b.mandateName])); + const results = await Promise.all( + Array.from(mandateIds).map(async (mid) => { + try { + const resp = await api.get(`/api/subscription/data-volume/${mid}`); + return { ...resp.data, mandateName: mandateNameMap.get(mid) || mid.slice(0, 8) } as DataVolumeInfo; + } catch { + return null; + } + }) + ); + setStorageData(results.filter((r): r is DataVolumeInfo => r !== null)); + } catch { + setStorageData([]); + } finally { + setStorageLoading(false); + } + }, [balances, selectedScope]); + + // Initial data load useEffect(() => { if (activeTab === 'overview' || activeTab === 'statistics') { _loadViewStatistics('month', new Date().getFullYear()); + _loadStorageData(); } - }, [activeTab, _loadViewStatistics, selectedScope]); + }, [activeTab, _loadViewStatistics, _loadStorageData, selectedScope]); // Load transactions with pagination support const _loadTransactions = useCallback(async (paginationParams?: any) => { @@ -584,6 +635,56 @@ export const BillingDataView: React.FC = () => { )} + {/* Storage quick info */} + {!storageLoading && storageData.length > 0 && ( +
+

Speicher

+
+ {storageData.map((sv) => { + const pct = sv.percentUsed ?? 0; + const barColor = pct >= 90 + ? 'var(--color-error, #ef4444)' + : pct >= 70 + ? 'var(--color-warning, #f59e0b)' + : 'var(--primary-color, #3b82f6)'; + return ( +
+

{sv.mandateName}

+
+ {formatBinaryDataSizeFromMebibytes(sv.usedMB)} + + / {sv.maxDataVolumeMB != null ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) : '∞'} + +
+ {sv.maxDataVolumeMB != null && ( +
+
0 ? '3px' : '0', + }} /> +
+ )} + {sv.warning && ( +
+ Speicher knapp +
+ )} +
+ ); + })} +
+
+ )} + {/* Usage Statistics via FormGeneratorReport */}
{ {/* Tab: Statistik (Dashboard) */} {/* ================================================================ */} {activeTab === 'statistics' && ( -
- -
+ <> + {/* Storage volume section */} +
+
+

+ Speicherverbrauch +

+ {storageLoading ? ( +
Lade Speicherdaten...
+ ) : storageData.length === 0 ? ( +
Keine Speicherdaten verfügbar
+ ) : ( +
+ {storageData.map((sv) => { + const usedLabel = formatBinaryDataSizeFromMebibytes(sv.usedMB); + const maxLabel = sv.maxDataVolumeMB != null + ? formatBinaryDataSizeFromMebibytes(sv.maxDataVolumeMB) + : 'unbegrenzt'; + const pct = sv.percentUsed ?? 0; + const barColor = pct >= 90 + ? 'var(--color-error, #ef4444)' + : pct >= 70 + ? 'var(--color-warning, #f59e0b)' + : 'var(--primary-color, #3b82f6)'; + + return ( +
+
+ + {sv.mandateName} + + + {usedLabel} / {maxLabel} + {sv.percentUsed != null && ( + + ({pct.toFixed(1)}%) + + )} + +
+ {sv.maxDataVolumeMB != null && ( +
+
0 ? '4px' : '0', + }} /> +
+ )} +
+ Dateien: {formatBinaryDataSizeFromMebibytes(sv.filesMB)} + RAG-Index: {formatBinaryDataSizeFromMebibytes(sv.ragIndexMB)} +
+
+ ); + })} +
+ )} +
+
+ + {/* AI usage statistics */} +
+ +
+ )} {/* ================================================================ */} diff --git a/src/pages/views/workspace/NeutralizationPanel.tsx b/src/pages/views/workspace/NeutralizationPanel.tsx index bcb0231..22a5812 100644 --- a/src/pages/views/workspace/NeutralizationPanel.tsx +++ b/src/pages/views/workspace/NeutralizationPanel.tsx @@ -32,8 +32,9 @@ const NeutralizationPanel: React.FC = ({ instanceId }) setLoading(true); try { const response = await api.get(`/api/workspace/${instanceId}/files`); - const files = response.data?.data || response.data || []; - const neutralized = files + const raw = response.data; + const files = Array.isArray(raw) ? raw : (raw?.files || raw?.data || []); + const neutralized = (Array.isArray(files) ? files : []) .filter((f: any) => f.neutralize) .map((f: any) => ({ fileId: f.id, From 9d4e5bc90d8c00284a1e3d39754894946ac6248b Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 30 Mar 2026 00:15:01 +0200 Subject: [PATCH 10/11] streamlined neutralization flow --- src/pages/Settings.tsx | 23 +- .../views/workspace/NeutralizationPanel.tsx | 394 +++++++++++++++--- .../views/workspace/WorkspaceSettingsPage.tsx | 11 +- 3 files changed, 361 insertions(+), 67 deletions(-) diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index 84691a2..bfe6de9 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -23,7 +23,7 @@ const _TABS: { key: SettingsTab; label: string }[] = [ { key: 'profile', label: 'Profil' }, { key: 'appearance', label: 'Darstellung' }, { key: 'voice', label: 'Stimme & Sprache' }, - { key: 'neutralization', label: 'Datenneutralisierung' }, + { key: 'neutralization', label: 'Neutralisierung (lokal)' }, { key: 'privacy', label: 'Datenschutz' }, ]; @@ -358,12 +358,27 @@ const NeutralizationMappingsTab: React.FC = () => { {error &&
{error}
}
-

Platzhalter-Mappings

+

Platzhalter-Mappings (lokal)

+
+ AI-Workspace: Neutralisierter Chat-Text, Dokumente und Platzhalter-Mappings finden Sie unter{' '} + Mandant → AI-Workspace-Instanz → Einstellungen → Tab „Neutralisierung“ (nicht auf dieser + Seite). Dieser Tab zeigt nur die lokale Liste über /api/local/neutralization-mappings. +

Bei der Datenneutralisierung werden personenbezogene Daten durch Platzhalter ersetzt, bevor Text an KI-Modelle geht; die Antwort wird anschliessend wieder mit Ihren Originalbegriffen angereichert (zentrale Pipeline ueber - den AI-Service). Diese Liste betrifft nur Ihre gespeicherten Platzhalter-Zuordnungen — hier einsehbar und - loeschbar. + den AI-Service). Die Tabelle unten betrifft nur lokale Entwickler-/Test-Mappings — hier einsehbar und loeschbar.

{mappings.length === 0 ? ( diff --git a/src/pages/views/workspace/NeutralizationPanel.tsx b/src/pages/views/workspace/NeutralizationPanel.tsx index 22a5812..c8e1179 100644 --- a/src/pages/views/workspace/NeutralizationPanel.tsx +++ b/src/pages/views/workspace/NeutralizationPanel.tsx @@ -1,6 +1,9 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import api from '../../../api'; +const _chatPromptSourceId = '__chat_prompt__'; +const _placeholderRx = /\[([a-z]+)\.([a-f0-9-]{36})\]/g; + interface NeutralizationMapping { id: string; originalText: string; @@ -11,38 +14,220 @@ interface NeutralizationMapping { createdAt?: string; } +interface NeutralizationSnapshot { + id: string; + sourceLabel: string; + neutralizedText: string; + placeholderCount: number; +} + interface NeutralizationSource { fileId: string; fileName: string; neutralizationStatus: string; mappingCount: number; + isVirtual?: boolean; } interface NeutralizationPanelProps { instanceId: string; } +function _normalizeApiRow(raw: Record): NeutralizationMapping { + const id = String(raw.id ?? ''); + const patternType = String(raw.patternType ?? 'unknown'); + const existingPh = raw.placeholder; + const placeholder = + typeof existingPh === 'string' && existingPh + ? existingPh + : id + ? `[${patternType}.${id}]` + : ''; + return { + id, + originalText: String(raw.originalText ?? ''), + placeholder, + patternType, + fileId: raw.fileId != null && raw.fileId !== '' ? String(raw.fileId) : undefined, + fileName: raw.fileName != null ? String(raw.fileName) : undefined, + createdAt: + raw.createdAt != null + ? String(raw.createdAt) + : raw.sysCreatedAt != null + ? String(raw.sysCreatedAt) + : undefined, + }; +} + +function _partitionAttributes(rows: unknown[]): { + byFile: Record; + unscoped: NeutralizationMapping[]; +} { + const byFile: Record = {}; + const unscoped: NeutralizationMapping[] = []; + for (const item of rows) { + if (!item || typeof item !== 'object') continue; + const raw = item as Record; + const m = _normalizeApiRow(raw); + const fid = raw.fileId; + if (fid == null || fid === '') { + unscoped.push(m); + } else { + const key = String(fid); + if (!byFile[key]) byFile[key] = []; + byFile[key].push(m); + } + } + return { byFile, unscoped }; +} + +const _phTypeColors: Record = { + name: '#7c3aed', + email: '#2563eb', + phone: '#0891b2', + address: '#059669', + financial: '#d97706', + id: '#dc2626', + logic: '#be185d', + company: '#4f46e5', + product: '#7c3aed', + location: '#059669', + other: '#6b7280', +}; + +function _renderHighlightedText( + text: string, + mappingLookup: Map, +): React.ReactNode[] { + const parts: React.ReactNode[] = []; + let lastIdx = 0; + const rx = new RegExp(_placeholderRx.source, 'g'); + let match: RegExpExecArray | null; + + while ((match = rx.exec(text)) !== null) { + if (match.index > lastIdx) { + parts.push({text.slice(lastIdx, match.index)}); + } + const phType = match[1]; + const phId = match[2]; + const fullPh = match[0]; + const mapping = mappingLookup.get(phId); + const color = _phTypeColors[phType] || _phTypeColors.other; + parts.push( + + {fullPh} + , + ); + lastIdx = match.index + match[0].length; + } + if (lastIdx < text.length) { + parts.push({text.slice(lastIdx)}); + } + return parts; +} + const NeutralizationPanel: React.FC = ({ instanceId }) => { const [sources, setSources] = useState([]); const [selectedSource, setSelectedSource] = useState(null); const [mappings, setMappings] = useState([]); const [loading, setLoading] = useState(true); + const [attributeByFile, setAttributeByFile] = useState>({}); + const [attributeUnscoped, setAttributeUnscoped] = useState([]); + const [snapshots, setSnapshots] = useState([]); + const [expandedSnapshot, setExpandedSnapshot] = useState(null); + + const _mappingLookup = useMemo(() => { + const map = new Map(); + for (const m of attributeUnscoped) map.set(m.id, m); + for (const arr of Object.values(attributeByFile)) { + for (const m of arr) map.set(m.id, m); + } + return map; + }, [attributeUnscoped, attributeByFile]); const _loadSources = useCallback(async () => { setLoading(true); try { - const response = await api.get(`/api/workspace/${instanceId}/files`); - const raw = response.data; - const files = Array.isArray(raw) ? raw : (raw?.files || raw?.data || []); - const neutralized = (Array.isArray(files) ? files : []) - .filter((f: any) => f.neutralize) - .map((f: any) => ({ - fileId: f.id, - fileName: f.fileName || f.name || 'unknown', - neutralizationStatus: f.neutralizationStatus || f.status || 'unknown', - mappingCount: 0, - })); - setSources(neutralized); + const headers = instanceId ? { 'X-Instance-Id': instanceId } : undefined; + const [filesResponse, attrResponse] = await Promise.all([ + api.get(`/api/workspace/${instanceId}/files`, { headers }), + api.get('/api/neutralization/attributes', { headers }), + ]); + + let snapAxios: { data: unknown } = { data: [] }; + try { + const _enc = encodeURIComponent(instanceId); + snapAxios = await api.get(`/api/neutralization/${_enc}/snapshots`, { headers }); + } catch (_snapErr) { + console.warn('NeutralizationPanel: scoped snapshots failed, trying unscoped:', _snapErr); + try { + snapAxios = await api.get('/api/neutralization/snapshots', { headers }); + } catch (_snapErr2) { + console.error('NeutralizationPanel: snapshots could not be loaded:', _snapErr2); + snapAxios = { data: [] }; + } + } + + const rawFiles = filesResponse.data; + const files = Array.isArray(rawFiles) ? rawFiles : (rawFiles?.files || rawFiles?.data || []); + const fileList = Array.isArray(files) ? files : []; + + const attrPayload = attrResponse.data?.data ?? attrResponse.data ?? []; + const attrRows = Array.isArray(attrPayload) ? attrPayload : []; + const { byFile, unscoped } = _partitionAttributes(attrRows); + setAttributeByFile(byFile); + setAttributeUnscoped(unscoped); + + const _snapBody = snapAxios.data as { data?: unknown } | unknown[] | null | undefined; + const snapPayload = + Array.isArray(_snapBody) ? _snapBody : (_snapBody && typeof _snapBody === 'object' && 'data' in _snapBody + ? ( _snapBody as { data: unknown }).data + : _snapBody) ?? []; + const snapList: NeutralizationSnapshot[] = Array.isArray(snapPayload) ? (snapPayload as NeutralizationSnapshot[]) : []; + setSnapshots(snapList); + if (snapList.length > 0 && snapList[0].id) { + setExpandedSnapshot(snapList[0].id); + } else { + setExpandedSnapshot(null); + } + + const neutralizedFiles = fileList.filter((f: Record) => f.neutralize); + + const nextSources: NeutralizationSource[] = []; + if (unscoped.length > 0) { + nextSources.push({ + fileId: _chatPromptSourceId, + fileName: 'Chat, Prompt & Kontext', + neutralizationStatus: 'completed', + mappingCount: unscoped.length, + isVirtual: true, + }); + } + for (const f of neutralizedFiles) { + const fid = String(f.id ?? ''); + if (!fid) continue; + nextSources.push({ + fileId: fid, + fileName: String(f.fileName ?? f.name ?? 'unknown'), + neutralizationStatus: String(f.neutralizationStatus ?? f.status ?? 'unknown'), + mappingCount: byFile[fid]?.length ?? 0, + }); + } + setSources(nextSources); } catch (err) { console.error('Failed to load neutralization sources:', err); } finally { @@ -50,35 +235,28 @@ const NeutralizationPanel: React.FC = ({ instanceId }) } }, [instanceId]); - const _loadMappings = useCallback(async (fileId: string) => { - try { - const response = await api.get(`/api/neutralization/${instanceId}/attributes`, { params: { fileId } }); - const data = response.data?.data || response.data || []; - setMappings(data.map((m: any) => ({ - id: m.id, - originalText: m.originalText || '', - placeholder: m.placeholder || m.id, - patternType: m.patternType || 'unknown', - fileId: m.fileId, - fileName: m.fileName, - createdAt: m.createdAt || m.sysCreatedAt, - }))); - } catch (err) { - console.error('Failed to load mappings:', err); - setMappings([]); - } - }, [instanceId]); - - useEffect(() => { _loadSources(); }, [_loadSources]); + useEffect(() => { + _loadSources(); + }, [_loadSources]); useEffect(() => { - if (selectedSource) _loadMappings(selectedSource); - }, [selectedSource, _loadMappings]); + if (!selectedSource) { + setMappings([]); + return; + } + if (selectedSource === _chatPromptSourceId) { + setMappings(attributeUnscoped); + return; + } + setMappings(attributeByFile[selectedSource] ?? []); + }, [selectedSource, attributeByFile, attributeUnscoped]); const _handleDeleteMapping = async (mappingId: string) => { try { - await api.delete(`/api/neutralization/${instanceId}/attributes/single/${mappingId}`); - setMappings(prev => prev.filter(m => m.id !== mappingId)); + await api.delete(`/api/neutralization/attributes/single/${mappingId}`, { + headers: instanceId ? { 'X-Instance-Id': instanceId } : undefined, + }); + await _loadSources(); } catch (err) { console.error('Failed to delete mapping:', err); } @@ -86,8 +264,12 @@ const NeutralizationPanel: React.FC = ({ instanceId }) const _handleRetrigger = async (fileId: string) => { try { - await api.post(`/api/neutralization/${instanceId}/retrigger`, { fileId }); - _loadSources(); + await api.post( + '/api/neutralization/retrigger', + { fileId }, + { headers: instanceId ? { 'X-Instance-Id': instanceId } : undefined }, + ); + await _loadSources(); } catch (err) { console.error('Failed to retrigger neutralization:', err); } @@ -110,26 +292,82 @@ const NeutralizationPanel: React.FC = ({ instanceId }) if (loading) return
Lade Neutralisierungsdaten...
; + const _hasAnyData = sources.length > 0 || snapshots.length > 0; + return (

Neutralisierung

- Übersicht aller Datenquellen mit Neutralisierung und deren Platzhalter-Mappings. + Neutralisierte Texte mit Platzhaltern und die zugehörigen Mappings (Original ↔ Platzhalter).

- {sources.length === 0 ? ( -
- Keine Datenquellen mit aktiver Neutralisierung. -
- ) : ( + {/* ── Snapshots: neutralisierter Text ──────────────────────── */} + {snapshots.length > 0 && (
+
+ Neutralisierter Text ({snapshots.length}) +
+ {snapshots.map((snap) => { + const _isExpanded = expandedSnapshot === snap.id; + return ( +
+
setExpandedSnapshot(_isExpanded ? null : snap.id)} + style={{ + padding: '8px 12px', + background: 'var(--bg-hover, #f9fafb)', + cursor: 'pointer', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + fontSize: '0.85rem', + }} + > + {snap.sourceLabel} + + {snap.placeholderCount} Platzhalter {_isExpanded ? '\u25BC' : '\u25B6'} + +
+ {_isExpanded && ( +
+ {_renderHighlightedText(snap.neutralizedText, _mappingLookup)} +
+ )} +
+ ); + })} +
+ )} + + {/* ── Quellen (Dateien + Chat/Prompt) ──────────────────────── */} + {sources.length > 0 && ( +
+
+ Datenquellen +
{sources.map((src) => (
setSelectedSource(src.fileId === selectedSource ? null : src.fileId)} > @@ -137,24 +375,38 @@ const NeutralizationPanel: React.FC = ({ instanceId })
{src.fileName}
{_statusBadge(src.neutralizationStatus)} + {src.mappingCount > 0 && ( + {src.mappingCount} Mapping(s) + )}
- - - {selectedSource === src.fileId ? '\u25BC' : '\u25B6'} - + {!src.isVirtual && ( + + )} + {selectedSource === src.fileId ? '\u25BC' : '\u25B6'}
))}
)} + {/* ── Mappings für ausgewählte Quelle ──────────────────────── */} {selectedSource && mappings.length > 0 && (
@@ -162,10 +414,22 @@ const NeutralizationPanel: React.FC = ({ instanceId })
{mappings.map((m) => ( -
- {m.placeholder} +
+ {m.placeholder} {'\u2192'} - {m.originalText} + + {m.originalText} + {m.patternType}
diff --git a/src/pages/views/workspace/WorkspaceSettingsPage.tsx b/src/pages/views/workspace/WorkspaceSettingsPage.tsx index 8f25088..644f253 100644 --- a/src/pages/views/workspace/WorkspaceSettingsPage.tsx +++ b/src/pages/views/workspace/WorkspaceSettingsPage.tsx @@ -14,7 +14,7 @@ type SettingsTab = 'general' | 'neutralization'; const _TABS: { key: SettingsTab; label: string }[] = [ { key: 'general', label: 'Generelle Einstellungen' }, - { key: 'neutralization', label: 'Neutralisierung' }, + { key: 'neutralization', label: 'Neutralisierung (Workspace)' }, ]; export const WorkspaceSettingsPage: React.FC = () => { @@ -67,7 +67,14 @@ export const WorkspaceSettingsPage: React.FC = () => { )} {activeTab === 'neutralization' && ( - + <> +

+ Hier erscheinen die zuletzt an die KI gesendeten neutralisierten Texte und Platzhalter dieser + Workspace-Instanz. (Die Benutzer-Einstellungen unter /settings → „Neutralisierung (lokal)“ + ist eine andere Seite.) +

+ + )}
From 9ea6ed46132f8b52af581c235e43a881d2c2095e Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Mon, 30 Mar 2026 23:03:33 +0200 Subject: [PATCH 11/11] fixed onboarding flow --- .../AutomationEditor/AutomationEditor.tsx | 19 +- src/components/OnboardingAssistant.tsx | 89 ++++--- src/components/OnboardingWizard.tsx | 23 +- .../ProviderSelector.module.css | 59 +++-- .../ProviderSelector/ProviderSelector.tsx | 233 +++++++++++------- src/components/ProviderSelector/index.ts | 15 +- src/hooks/usePrompt.tsx | 8 +- src/pages/Login.tsx | 11 +- src/pages/Register.tsx | 62 +---- .../admin/wizards/AdminMandateWizardPage.tsx | 1 + src/pages/views/workspace/WorkspaceInput.tsx | 16 +- src/pages/views/workspace/WorkspacePage.tsx | 13 +- 12 files changed, 308 insertions(+), 241 deletions(-) diff --git a/src/components/AutomationEditor/AutomationEditor.tsx b/src/components/AutomationEditor/AutomationEditor.tsx index 56524d9..aa81bc0 100644 --- a/src/components/AutomationEditor/AutomationEditor.tsx +++ b/src/components/AutomationEditor/AutomationEditor.tsx @@ -14,7 +14,9 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react' import { FaSave, FaChevronLeft, FaChevronRight, FaCode, FaExclamationTriangle, FaMagic, FaFolder, FaFolderOpen, FaArrowUp, FaSpinner } from 'react-icons/fa'; import { Popup } from '../UiComponents/Popup'; import { ActionsPanel } from '../ActionsPanel'; -import { ProviderMultiSelect } from '../ProviderSelector'; +import { ProviderMultiSelect, _defaultProviderSelection, _migrateFromLegacy, _toBackendProviders } from '../ProviderSelector'; +import type { ProviderSelection } from '../ProviderSelector'; +import { useBilling } from '../../hooks/useBilling'; import { useToast } from '../../contexts/ToastContext'; import { useLanguage } from '../../providers/language/LanguageContext'; import { useWorkflowActions } from '../../hooks/useAutomations'; @@ -374,7 +376,8 @@ export const AutomationEditor: React.FC = ({ const [label, setLabel] = useState(''); const [schedule, setSchedule] = useState('0 22 * * *'); const [active, setActive] = useState(false); - const [allowedProviders, setAllowedProviders] = useState([]); + const [providerSelection, setProviderSelection] = useState(_defaultProviderSelection()); + const { allowedProviders: billingProviders } = useBilling(); // Template multilingual fields const [labelMulti, setLabelMulti] = useState({ en: '', de: '' }); @@ -537,7 +540,7 @@ export const AutomationEditor: React.FC = ({ setLabel(def.label || ''); setSchedule(def.schedule || '0 22 * * *'); setActive(def.active ?? false); - setAllowedProviders(def.allowedProviders || []); + setProviderSelection(_migrateFromLegacy(def.allowedProviders || [])); } // Extract template JSON @@ -693,7 +696,7 @@ export const AutomationEditor: React.FC = ({ active, template: templateJson, placeholders, - allowedProviders + allowedProviders: _toBackendProviders(providerSelection, billingProviders), }; } @@ -709,7 +712,7 @@ export const AutomationEditor: React.FC = ({ } finally { setIsSaving(false); } - }, [label, schedule, active, allowedProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]); + }, [label, schedule, active, providerSelection, billingProviders, labelMulti, overviewMulti, templateJson, placeholders, mode, initialData, onSave, showError]); // Computed values const editorTitle = title || (mode === 'template' @@ -864,12 +867,12 @@ export const AutomationEditor: React.FC = ({ {/* Allowed AI Providers */}

- Beschränkt die Automation auf bestimmte AI-Provider. Leer = alle erlaubt. + Beschränkt die Automation auf bestimmte AI-Provider. «Alle» = dynamisch alle erlaubten.

diff --git a/src/components/OnboardingAssistant.tsx b/src/components/OnboardingAssistant.tsx index 689d624..2ea90ec 100644 --- a/src/components/OnboardingAssistant.tsx +++ b/src/components/OnboardingAssistant.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { useNavigate, useLocation } from 'react-router-dom'; import api from '../api'; +import OnboardingWizard from './OnboardingWizard'; interface OnboardingStep { id: string; @@ -17,7 +18,7 @@ interface OnboardingAssistantProps { const _STORAGE_KEY = 'onboarding_hidden'; const _CALLOUTS: Record = { - mandate: 'Tipp: Ein Mandant ist Ihr persoenlicher Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.', + mandate: 'Tipp: Ein Mandant ist Ihr Arbeitsbereich. Sie koennen spaeter weitere Mandanten fuer Teams oder Projekte erstellen.', feature: 'Tipp: Im Store finden Sie AI-Workspace, CommCoach und weitere Features. Aktivieren Sie mindestens eines, um loszulegen.', connection: 'Tipp: Verbinden Sie Ihre Datenquellen (z.B. SharePoint, Google Drive), damit der AI-Assistent auf Ihre Dokumente zugreifen kann.', chat: 'Tipp: Starten Sie einen Chat mit dem AI-Assistenten. Er kann Ihre verbundenen Daten analysieren und Fragen beantworten.', @@ -50,46 +51,59 @@ const OnboardingAssistant: React.FC = ({ onDismiss }) const [steps, setSteps] = useState([]); const [loading, setLoading] = useState(true); const [dontShowAgain, setDontShowAgain] = useState(false); + const [showWizard, setShowWizard] = useState(false); const _checkOnboardingState = useCallback(async () => { setLoading(true); try { const onboardingSteps: OnboardingStep[] = []; - let hasMandate = false; + // Check admin mandates (user-owned or where user is admin) + let hasAdminMandate = false; try { const mandatesRes = await api.get('/api/store/mandates'); const mandates = mandatesRes.data?.mandates || mandatesRes.data || []; - hasMandate = Array.isArray(mandates) && mandates.length > 0; + hasAdminMandate = Array.isArray(mandates) && mandates.length > 0; } catch { /* ignore */ } - onboardingSteps.push({ - id: 'mandate', - label: 'Mandant einrichten', - description: hasMandate - ? 'Dein Mandant ist eingerichtet.' - : 'Richte deinen ersten Mandanten ein.', - completed: hasMandate, - action: hasMandate ? undefined : () => navigate('/store'), - }); - + // Check if user has any feature access (via navigation = mandate member) let hasFeature = false; - let firstInstancePath: string | undefined; + let workspaceInstancePath: string | undefined; + let workspaceInstanceIds: string[] = []; try { const navRes = await api.get('/api/navigation?language=de'); - const mandates = navRes.data?.mandates || []; + const blocks = navRes.data?.blocks || []; + const dynamicBlock = blocks.find((b: { type: string }) => b.type === 'dynamic'); + const mandates = dynamicBlock?.mandates || []; for (const m of mandates) { for (const f of m.features || []) { for (const inst of f.instances || []) { - if (!hasFeature) hasFeature = true; - if (!firstInstancePath && inst.views?.length > 0) { - firstInstancePath = inst.views[0].uiPath; + hasFeature = true; + if (f.uiComponent === 'feature.workspace' && inst.views?.length > 0) { + workspaceInstanceIds.push(inst.id); + if (!workspaceInstancePath) { + workspaceInstancePath = inst.views[0].uiPath; + } } } } } } catch { /* ignore */ } + const mandateStepDone = hasAdminMandate || hasFeature; + + onboardingSteps.push({ + id: 'mandate', + label: 'Mandant einrichten', + description: hasAdminMandate + ? 'Dein Mandant ist eingerichtet.' + : hasFeature + ? 'Du bist Mitglied eines Mandanten.' + : 'Erstelle deinen Arbeitsbereich.', + completed: mandateStepDone, + action: mandateStepDone ? undefined : () => setShowWizard(true), + }); + onboardingSteps.push({ id: 'feature', label: 'Erstes Feature aktivieren', @@ -103,8 +117,8 @@ const OnboardingAssistant: React.FC = ({ onDismiss }) let hasConnection = false; try { const connRes = await api.get('/api/connections/'); - const connections = connRes.data?.data || connRes.data || []; - hasConnection = Array.isArray(connections) && connections.length > 0; + const items = connRes.data?.items || connRes.data?.data || connRes.data || []; + hasConnection = Array.isArray(items) && items.length > 0; } catch { /* ignore */ } onboardingSteps.push({ @@ -118,25 +132,16 @@ const OnboardingAssistant: React.FC = ({ onDismiss }) }); let hasChat = false; - if (hasFeature && firstInstancePath) { + for (const instId of workspaceInstanceIds) { + if (hasChat) break; try { - const featuresRes = await api.get('/api/store/features'); - const features = featuresRes.data || []; - for (const f of features) { - if (hasChat) break; - for (const inst of f.instances || []) { - if (hasChat) break; - try { - const wfRes = await api.get(`/api/workspace/${inst.id}/workflows`); - const wfs = wfRes.data?.workflows || wfRes.data?.data || []; - if (Array.isArray(wfs) && wfs.length > 0) hasChat = true; - } catch { /* ignore */ } - } - } + const wfRes = await api.get(`/api/workspace/${instId}/workflows`); + const wfs = wfRes.data?.workflows || wfRes.data?.data || wfRes.data?.items || []; + if (Array.isArray(wfs) && wfs.length > 0) hasChat = true; } catch { /* ignore */ } } - const _chatAction = firstInstancePath ? () => navigate(firstInstancePath!) : undefined; + const chatAction = workspaceInstancePath ? () => navigate(workspaceInstancePath!) : undefined; onboardingSteps.push({ id: 'chat', label: 'Ersten AI-Chat starten', @@ -144,7 +149,7 @@ const OnboardingAssistant: React.FC = ({ onDismiss }) ? 'Du hast bereits Chats gestartet.' : 'Starte deinen ersten Chat mit dem AI-Assistenten.', completed: hasChat, - action: hasChat ? undefined : _chatAction, + action: hasChat ? undefined : chatAction, }); setSteps(onboardingSteps); @@ -180,6 +185,18 @@ const OnboardingAssistant: React.FC = ({ onDismiss }) onDismiss?.(); }; + if (showWizard) { + return ( + { + setShowWizard(false); + _checkOnboardingState(); + }} + onDismiss={() => setShowWizard(false)} + /> + ); + } + if (hidden || loading) return null; const completedCount = steps.filter(s => s.completed).length; diff --git a/src/components/OnboardingWizard.tsx b/src/components/OnboardingWizard.tsx index a1e9fa4..ae8f4c1 100644 --- a/src/components/OnboardingWizard.tsx +++ b/src/components/OnboardingWizard.tsx @@ -8,7 +8,7 @@ interface OnboardingWizardProps { const OnboardingWizard: React.FC = ({ onComplete, onDismiss }) => { const [planKey, setPlanKey] = useState<'TRIAL_7D' | 'STANDARD_MONTHLY'>('TRIAL_7D'); - const [companyName, setCompanyName] = useState(''); + const [mandateName, setMandateName] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -16,10 +16,15 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi setLoading(true); setError(null); try { - await api.post('/api/local/onboarding', { + const res = await api.post('/api/local/onboarding', { planKey, - companyName: companyName.trim() || undefined, + companyName: mandateName.trim() || undefined, }); + if (res.data?.alreadyProvisioned) { + setError('Du hast bereits einen Mandanten mit Admin-Zugang.'); + return; + } + window.dispatchEvent(new CustomEvent('features-changed')); onComplete(); } catch (err: any) { setError(err?.response?.data?.detail || 'Fehler bei der Einrichtung'); @@ -38,9 +43,9 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi background: 'var(--bg-primary, #fff)', borderRadius: '12px', padding: '32px', maxWidth: '480px', width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.2)', }}> -

Willkommen bei PowerOn

+

Mandant erstellen

- Wähle dein Abo und leg los. + Erstelle deinen eigenen Arbeitsbereich mit Abo-Auswahl.

@@ -80,8 +85,8 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi Name des Mandanten (optional) setCompanyName(e.target.value)} + type="text" value={mandateName} + onChange={(e) => setMandateName(e.target.value)} placeholder="z. B. Firmenname oder Projektname" style={{ width: '100%', padding: '10px 12px', borderRadius: '6px', @@ -98,7 +103,7 @@ const OnboardingWizard: React.FC = ({ onComplete, onDismi padding: '10px 20px', borderRadius: '6px', border: '1px solid var(--border, #d1d5db)', background: 'transparent', cursor: 'pointer', }}> - Später + Abbrechen
diff --git a/src/components/ProviderSelector/ProviderSelector.module.css b/src/components/ProviderSelector/ProviderSelector.module.css index 04d2be7..384c1c5 100644 --- a/src/components/ProviderSelector/ProviderSelector.module.css +++ b/src/components/ProviderSelector/ProviderSelector.module.css @@ -53,17 +53,17 @@ justify-content: center; width: 36px; height: 36px; - border: 1px solid var(--border-color, #3a3a3a); + border: 1px solid var(--border-color, #e0e0e0); border-radius: 6px; - background: var(--surface-color, #2d2d2d); - color: var(--text-secondary, #888); + background: var(--surface-color, #ffffff); + color: var(--text-secondary, #666666); cursor: pointer; transition: all 0.2s; } .triggerButton:hover:not(:disabled) { - background: var(--bg-secondary, #3a3a3a); - color: var(--text-primary, #fff); + background: var(--hover-bg, rgba(0, 0, 0, 0.06)); + color: var(--text-primary, #1a1a1a); } .triggerButton:disabled { @@ -83,20 +83,20 @@ transform: translateX(-50%); z-index: 1000; padding: 8px; - background: var(--surface-color, #2d2d2d); - border: 1px solid var(--border-color, #3a3a3a); + background: var(--surface-color, #ffffff); + border: 1px solid var(--border-color, #e0e0e0); border-radius: 6px; - box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.5); + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.12); min-width: 220px; } .dropdownHeader { font-size: 0.75rem; font-weight: 500; - color: var(--text-secondary, #888); + color: var(--text-secondary, #666666); padding: 4px 8px; margin-bottom: 4px; - border-bottom: 1px solid var(--border-color, #3a3a3a); + border-bottom: 1px solid var(--border-color, #e0e0e0); } .selectActions { @@ -108,18 +108,18 @@ .actionButton { flex: 1; padding: 4px 8px; - border: 1px solid var(--border-color, #3a3a3a); + border: 1px solid var(--border-color, #e0e0e0); border-radius: 4px; - background: var(--bg-secondary, #252525); - color: var(--text-secondary, #888); + background: var(--bg-secondary, #f8f9fa); + color: var(--text-secondary, #666666); font-size: 0.75rem; cursor: pointer; transition: all 0.2s ease; } .actionButton:hover:not(:disabled) { - background: var(--bg-hover, #3a3a3a); - color: var(--text-primary, #fff); + background: var(--hover-bg, rgba(0, 0, 0, 0.06)); + color: var(--text-primary, #1a1a1a); } .actionButton.active { @@ -138,7 +138,7 @@ flex-direction: column; gap: 2px; padding: 4px; - background: var(--bg-secondary, #252525); + background: var(--bg-secondary, #f8f9fa); border-radius: 4px; max-height: 200px; overflow-y: auto; @@ -151,12 +151,13 @@ padding: 6px 8px; border-radius: 4px; cursor: pointer; - transition: background 0.15s ease; - color: var(--text-primary, #e0e0e0); + transition: background 0.15s ease, color 0.15s ease; + color: var(--text-primary, #1a1a1a); } .checkboxItem:hover { - background: var(--bg-hover, #3a3a3a); + background: var(--hover-bg, rgba(0, 0, 0, 0.06)); + color: var(--text-primary, #1a1a1a); } .checkboxItem.disabled { @@ -177,12 +178,12 @@ .providerName { font-size: 0.8rem; - color: var(--text-primary, #e0e0e0); + color: inherit; } .hint { font-size: 0.7rem; - color: var(--text-tertiary, #666); + color: var(--text-tertiary, #888888); text-align: center; padding: 4px 0; } @@ -192,10 +193,24 @@ align-items: center; justify-content: center; padding: 12px; - color: var(--text-secondary, #888); + color: var(--text-secondary, #666666); font-size: 0.8rem; } +/* Dark theme: list hover stays a light lift, not a black wash */ +:global(.dark-theme) .checkboxItem:hover { + background: var(--hover-bg, rgba(255, 255, 255, 0.08)); + color: var(--text-primary, #e5e7eb); +} + +:global(.dark-theme) .checkboxItem { + color: var(--text-primary, #e5e7eb); +} + +:global(.dark-theme) .dropdownContent { + box-shadow: 0 -4px 24px rgba(0, 0, 0, 0.45); +} + /* ============================================================================ PROVIDER BADGES ============================================================================ */ diff --git a/src/components/ProviderSelector/ProviderSelector.tsx b/src/components/ProviderSelector/ProviderSelector.tsx index e41cefb..24cfebc 100644 --- a/src/components/ProviderSelector/ProviderSelector.tsx +++ b/src/components/ProviderSelector/ProviderSelector.tsx @@ -1,19 +1,80 @@ /** * ProviderSelector Component - * + * * Wiederverwendbare Komponente zur Auswahl von AICore-Providern. * Kann im AI Workspace und Automation Editor verwendet werden. - * - * Features: - * - Dropdown für Einzelauswahl - * - Checkbox-Liste für Mehrfachauswahl - * - Lädt verfügbare Provider aus dem Billing-System + * + * Selektionsmodell: + * ProviderSelection { include: string[], exclude: string[] } + * - include(["ALL"]), exclude([]) → alle verfügbaren Provider (dynamisch) + * - include(["ALL"]), exclude(["private"]) → alle ausser "private" (dynamisch) + * - include(["anthropic"]), exclude([]) → nur Anthropic + * - include([]), exclude([]) → keiner ausgewählt + * + * resolveProviders(selection, allowedProviders) liefert die konkrete Liste. */ import React, { useEffect, useMemo, useState, useRef, useCallback } from 'react'; import { useBilling } from '../../hooks/useBilling'; import styles from './ProviderSelector.module.css'; +// ============================================================================ +// TYPES & HELPERS +// ============================================================================ + +export const PROVIDER_ALL = 'ALL'; + +export interface ProviderSelection { + include: string[]; + exclude: string[]; +} + +export function _defaultProviderSelection(): ProviderSelection { + return { include: [PROVIDER_ALL], exclude: [] }; +} + +export function _resolveProviders( + selection: ProviderSelection, + allowedProviders: string[], +): string[] { + if (selection.include.includes(PROVIDER_ALL)) { + return allowedProviders.filter((p) => !selection.exclude.includes(p)); + } + return selection.include.filter((p) => allowedProviders.includes(p)); +} + +export function _isAllSelected(selection: ProviderSelection): boolean { + return selection.include.includes(PROVIDER_ALL) && selection.exclude.length === 0; +} + +export function _isNoneSelected( + selection: ProviderSelection, + allowedProviders: string[], +): boolean { + return _resolveProviders(selection, allowedProviders).length === 0; +} + +/** + * Migrate legacy string[] (old model) to ProviderSelection. + * [] → ALL, [...ids] → include those ids. + */ +export function _migrateFromLegacy(providers: string[]): ProviderSelection { + if (providers.length === 0) return _defaultProviderSelection(); + return { include: providers, exclude: [] }; +} + +/** + * Convert ProviderSelection to flat list for backend API calls. + * Returns [] when ALL are selected (= no restriction / legacy behaviour). + */ +export function _toBackendProviders( + selection: ProviderSelection, + allowedProviders: string[], +): string[] { + if (_isAllSelected(selection)) return []; + return _resolveProviders(selection, allowedProviders); +} + // Provider display names const PROVIDER_LABELS: Record = { anthropic: 'Anthropic (Claude)', @@ -25,7 +86,6 @@ const PROVIDER_LABELS: Record = { internal: 'Internal', }; -// Provider icons (emojis for simplicity) const PROVIDER_ICONS: Record = { anthropic: '🤖', openai: '💬', @@ -58,20 +118,20 @@ export const ProviderSelect: React.FC = ({ showLabel = true, }) => { const { allowedProviders, loadAllowedProviders, loading } = useBilling(); - + useEffect(() => { if (allowedProviders.length === 0 && !loading) { loadAllowedProviders(); } }, []); - + const providerOptions = useMemo(() => { return allowedProviders.map((provider) => ({ value: provider, label: `${PROVIDER_ICONS[provider] || '🔌'} ${PROVIDER_LABELS[provider] || provider}`, })); }, [allowedProviders]); - + return (
{showLabel && } @@ -93,12 +153,12 @@ export const ProviderSelect: React.FC = ({ }; // ============================================================================ -// MULTI SELECT COMPONENT (Checkbox List) +// MULTI SELECT COMPONENT (Checkbox List) — include / exclude model // ============================================================================ interface ProviderMultiSelectProps { - selectedProviders: string[]; - onChange: (providers: string[]) => void; + selection: ProviderSelection; + onChange: (selection: ProviderSelection) => void; disabled?: boolean; className?: string; label?: string; @@ -108,7 +168,7 @@ interface ProviderMultiSelectProps { } export const ProviderMultiSelect: React.FC = ({ - selectedProviders, + selection, onChange, disabled = false, className, @@ -121,97 +181,100 @@ export const ProviderMultiSelect: React.FC = ({ const [initialExcludeApplied, setInitialExcludeApplied] = useState(false); const containerRef = useRef(null); const { allowedProviders, loadAllowedProviders, loading } = useBilling(); - + useEffect(() => { if (allowedProviders.length === 0 && !loading) { loadAllowedProviders(); } }, []); - - // Apply default exclusions when providers first load + + // Apply default exclusions once when providers first load useEffect(() => { if ( !initialExcludeApplied && allowedProviders.length > 0 && excludeByDefault.length > 0 && - selectedProviders.length === 0 + _isAllSelected(selection) ) { - const initialSelection = allowedProviders.filter( - (p) => !excludeByDefault.includes(p) - ); - // Only apply if there's actually something to exclude - if (initialSelection.length < allowedProviders.length) { - onChange(initialSelection); - } + onChange({ include: [PROVIDER_ALL], exclude: [...excludeByDefault] }); setInitialExcludeApplied(true); } - }, [allowedProviders, excludeByDefault, initialExcludeApplied, selectedProviders.length, onChange]); - - // Click outside handler - const handleClickOutside = useCallback((event: MouseEvent) => { + }, [allowedProviders, excludeByDefault, initialExcludeApplied, selection, onChange]); + + const _handleClickOutside = useCallback((event: MouseEvent) => { if (containerRef.current && !containerRef.current.contains(event.target as Node)) { setIsExpanded(false); } }, []); - + useEffect(() => { if (isExpanded) { - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); + document.addEventListener('mousedown', _handleClickOutside); + return () => document.removeEventListener('mousedown', _handleClickOutside); } - }, [isExpanded, handleClickOutside]); - - // Effective selection: empty array = all providers active (no restriction) - const effectiveSelection = selectedProviders.length === 0 ? allowedProviders : selectedProviders; - - // "Alle" is active when no restriction is set (empty array) OR all explicitly selected - const isAllSelected = selectedProviders.length === 0 || - (allowedProviders.length > 0 && selectedProviders.length === allowedProviders.length); - - const handleToggle = (provider: string) => { - if (selectedProviders.length === 0) { - // Currently "all active" (no restriction) -> make explicit: all except the toggled one - onChange(allowedProviders.filter((p) => p !== provider)); - } else if (selectedProviders.includes(provider)) { - // Deactivate: remove from selection - const remaining = selectedProviders.filter((p) => p !== provider); - // If removing leaves all others selected, reset to [] (= all, no restriction) - if (remaining.length === allowedProviders.length) { - onChange([]); + }, [isExpanded, _handleClickOutside]); + + const effectiveSelection = useMemo( + () => _resolveProviders(selection, allowedProviders), + [selection, allowedProviders], + ); + + const allSelected = _isAllSelected(selection); + const noneSelected = effectiveSelection.length === 0; + + const _handleToggle = (provider: string) => { + const isChecked = effectiveSelection.includes(provider); + + if (selection.include.includes(PROVIDER_ALL)) { + // Currently ALL-based: toggle modifies exclude list + if (isChecked) { + onChange({ include: [PROVIDER_ALL], exclude: [...selection.exclude, provider] }); } else { - onChange(remaining); + const nextExclude = selection.exclude.filter((p) => p !== provider); + onChange({ include: [PROVIDER_ALL], exclude: nextExclude }); } } else { - // Activate: add to selection - const updated = [...selectedProviders, provider]; - // If all are now selected, reset to [] (= all, no restriction) - if (updated.length === allowedProviders.length) { - onChange([]); + // Explicit include list + if (isChecked) { + onChange({ include: selection.include.filter((p) => p !== provider), exclude: [] }); } else { - onChange(updated); + const nextInclude = [...selection.include, provider]; + if (nextInclude.length === allowedProviders.length) { + onChange({ include: [PROVIDER_ALL], exclude: [] }); + } else { + onChange({ include: nextInclude, exclude: [] }); + } } } }; - - const handleSelectAll = () => { - onChange([]); // Empty = all active, no restriction + + const _handleSelectAll = () => { + onChange({ include: [PROVIDER_ALL], exclude: [] }); }; - - // Summary icon for button + const summaryIcon = useMemo(() => { + if (noneSelected) return '⊘'; if (effectiveSelection.length === 1) { return PROVIDER_ICONS[effectiveSelection[0]] || '🔌'; } - return '🤖'; - }, [effectiveSelection]); - + return '⚡'; + }, [effectiveSelection, noneSelected]); + + const summaryHint = useMemo(() => { + if (noneSelected) return 'Kein Provider ausgewählt'; + if (allSelected) return 'Alle Provider aktiv (dynamisch)'; + if (selection.include.includes(PROVIDER_ALL)) { + return `Alle ausser ${selection.exclude.length} Provider`; + } + return `${effectiveSelection.length} von ${allowedProviders.length} Provider`; + }, [noneSelected, allSelected, selection, effectiveSelection, allowedProviders]); + return ( -
- {/* Trigger Button - styled like iconButton */} - - - {/* Dropdown Content */} + {isExpanded && (
{showLabel &&
{label}
} - +
-
- + {loading ? (
Lade...
) : (
{allowedProviders.map((provider) => ( -
)} - - {isAllSelected && !loading && ( -
- Alle Provider aktiv (kein Filter) -
- )} + +
{summaryHint}
)}
@@ -288,7 +346,7 @@ export const ProviderBadges: React.FC = ({ if (providers.length === 0) { return Alle Provider; } - + return (
{providers.map((provider) => ( @@ -300,5 +358,4 @@ export const ProviderBadges: React.FC = ({ ); }; -// Default export export default ProviderSelect; diff --git a/src/components/ProviderSelector/index.ts b/src/components/ProviderSelector/index.ts index afe1b42..a2f6c79 100644 --- a/src/components/ProviderSelector/index.ts +++ b/src/components/ProviderSelector/index.ts @@ -5,6 +5,19 @@ export { ProviderSelect, ProviderMultiSelect, - ProviderBadges + ProviderBadges, } from './ProviderSelector'; + +export { + PROVIDER_ALL, + _defaultProviderSelection, + _resolveProviders, + _isAllSelected, + _isNoneSelected, + _migrateFromLegacy, + _toBackendProviders, +} from './ProviderSelector'; + +export type { ProviderSelection } from './ProviderSelector'; + export { default } from './ProviderSelector'; diff --git a/src/hooks/usePrompt.tsx b/src/hooks/usePrompt.tsx index b3117a2..c0c8837 100644 --- a/src/hooks/usePrompt.tsx +++ b/src/hooks/usePrompt.tsx @@ -82,7 +82,7 @@ export function usePrompt() { onClick={(e) => e.stopPropagation()} style={{ background: 'var(--surface-color, #1a1a2e)', - border: '1px solid var(--color-border, #333)', + border: '1px solid var(--border-color, var(--color-border, #333))', borderRadius: '12px', padding: '1.5rem', minWidth: 360, maxWidth: 500, @@ -116,9 +116,9 @@ export function usePrompt() { style={{ padding: '10px 14px', borderRadius: '8px', - border: '1px solid var(--color-border, #444)', - background: 'var(--input-bg, #0d0d1a)', - color: 'var(--text-primary, #e0e0e0)', + border: '1px solid var(--border-color, var(--color-border, #ccc))', + background: 'var(--input-bg, var(--bg-primary, #ffffff))', + color: 'var(--text-primary, #1a1a1a)', fontSize: '0.9rem', outline: 'none', width: '100%', diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 6fa20a7..4ac295f 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -240,16 +240,9 @@ function Login() { -
diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 95051cd..8060b2c 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import { FaEnvelopeOpenText } from 'react-icons/fa'; import styles from './Register.module.css'; @@ -19,7 +19,6 @@ function Register() { const { register, error: registerError, isLoading } = useRegister(); const { error: msalError } = useMsalRegister(); const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability(); - // Pre-fill from invitation if provided via location.state const invitationUsername = (location.state as any)?.invitationUsername || ''; const invitationEmail = (location.state as any)?.invitationEmail || ''; const [formData, setFormData] = useState({ @@ -27,10 +26,6 @@ function Register() { email: invitationEmail, fullName: '' }); - const [searchParams] = useSearchParams(); - const registrationType = searchParams.get('type') === 'company' ? 'company' : 'personal'; - const [companyName, setCompanyName] = useState(''); - const [companyNameFocused, setCompanyNameFocused] = useState(false); const [validationError, setValidationError] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [usernameFocused, setUsernameFocused] = useState(false); @@ -38,19 +33,13 @@ function Register() { const [fullNameFocused, setFullNameFocused] = useState(false); const [usernameHighlight, setUsernameHighlight] = useState(false); - // Check for pending invitation const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); const hasPendingInvitation = !!pendingInvitationToken; - // Set page title and generate CSRF token useEffect(() => { - document.title = registrationType === 'company' - ? "PowerOn AI Platform - Unternehmenskonto erstellen" - : "PowerOn AI Platform - Kostenlos testen"; - - // Generate CSRF token for new security implementation + document.title = "PowerOn AI Platform - Registrieren"; generateAndStoreCSRFToken(); - }, [registrationType]); + }, []); const handleInputChange = (e: React.ChangeEvent) => { const { name, value } = e.target; @@ -59,13 +48,12 @@ function Register() { [name]: value })); setValidationError(null); - // Reset username highlight when user starts typing in username field if (name === 'username') { setUsernameHighlight(false); } }; - const validateForm = (): boolean => { + const _validateForm = (): boolean => { if (!formData.username || !formData.email || !formData.fullName) { setValidationError('Bitte füllen Sie alle Pflichtfelder aus.'); return false; @@ -76,27 +64,20 @@ function Register() { return false; } - if (registrationType === 'company' && !companyName.trim()) { - setValidationError('Bitte geben Sie einen Firmennamen ein.'); - return false; - } - return true; }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (!validateForm()) { + if (!_validateForm()) { return; } try { - // First check username availability const availabilityResult = await checkAvailability(formData.username, 'local'); if (!availabilityResult.available) { - // Check if the error message is about username being taken const errorMessage = availabilityResult.message || 'Username is not available'; if (errorMessage === 'Username is already taken') { setValidationError('Benutzername ist bereits vergeben'); @@ -107,25 +88,20 @@ function Register() { return; } - // Username is available, proceed with registration (no password - magic link flow) - await register({ ...formData, registrationType, companyName: registrationType === 'company' ? companyName : undefined }); + await register({ ...formData, registrationType: 'personal' }); - // Build success message let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.'; if (hasPendingInvitation) { message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.'; } - // Show success message instead of immediate redirect setSuccessMessage(message); - // Redirect to login page after delay setTimeout(() => { navigate('/login', { state: { registered: true, message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.', - // Pass along invitation state ...(location.state || {}) } }); @@ -135,8 +111,7 @@ function Register() { } }; - // Helper function to safely get error message - const getErrorMessage = () => { + const _getErrorMessage = () => { if (validationError) return validationError; if (registerError) return typeof registerError === 'string' ? registerError : 'Registration failed'; if (msalError) return typeof msalError === 'string' ? msalError : 'Microsoft registration failed'; @@ -157,7 +132,6 @@ function Register() {
- {/* Pending invitation notice */} {hasPendingInvitation && !successMessage && (
@@ -165,8 +139,8 @@ function Register() {
)} - {getErrorMessage() && ( -
{getErrorMessage()}
+ {_getErrorMessage() && ( +
{_getErrorMessage()}
)} {successMessage && ( @@ -203,22 +177,6 @@ function Register() {
- {registrationType === 'company' && ( -
- setCompanyName(e.target.value)} - onFocus={() => setCompanyNameFocused(true)} - onBlur={() => setCompanyNameFocused(false)} - className={`${styles.input} ${companyNameFocused || companyName ? styles.focused : ''}`} - /> - -
- )} -
- {isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : registrationType === 'company' ? 'Unternehmenskonto erstellen' : 'Kostenlos testen'} + {isLoading ? "Registrierung läuft..." : isChecking ? "Benutzername wird geprüft..." : 'Kostenlos registrieren'} )} diff --git a/src/pages/admin/wizards/AdminMandateWizardPage.tsx b/src/pages/admin/wizards/AdminMandateWizardPage.tsx index 43814f8..1597127 100644 --- a/src/pages/admin/wizards/AdminMandateWizardPage.tsx +++ b/src/pages/admin/wizards/AdminMandateWizardPage.tsx @@ -236,6 +236,7 @@ export const AdminMandateWizardPage: React.FC = () => { if (billingSaved) { showSuccess('Erstellt', 'Mandant inkl. Abrechnung gespeichert'); } + window.dispatchEvent(new CustomEvent('features-changed')); await loadMandates(); } catch (err: unknown) { const e = err as { response?: { data?: { detail?: string } }; message?: string }; diff --git a/src/pages/views/workspace/WorkspaceInput.tsx b/src/pages/views/workspace/WorkspaceInput.tsx index 9bc03a8..7138661 100644 --- a/src/pages/views/workspace/WorkspaceInput.tsx +++ b/src/pages/views/workspace/WorkspaceInput.tsx @@ -5,6 +5,7 @@ import React, { useState, useCallback, useRef, useEffect } from 'react'; import { ProviderMultiSelect } from '../../../components/ProviderSelector'; +import type { ProviderSelection } from '../../../components/ProviderSelector'; import { getPageIcon } from '../../../config/pageRegistry'; import { useVoiceStream } from '../../../hooks/useSpeechAudioCapture'; import type { WorkspaceFile, DataSource, FeatureDataSource } from './useWorkspace'; @@ -48,8 +49,8 @@ interface WorkspaceInputProps { onRemovePendingFile?: (fileId: string) => void; onFileUploadClick?: () => void; uploading?: boolean; - selectedProviders?: string[]; - onProvidersChange?: (providers: string[]) => void; + providerSelection?: ProviderSelection; + onProviderSelectionChange?: (selection: ProviderSelection) => void; isMobile?: boolean; onTreeItemsDrop?: (items: TreeItemDrop[]) => void; onPasteAsFile?: (file: File) => void; @@ -69,8 +70,8 @@ export const WorkspaceInput: React.FC = ({ onRemovePendingFile, onFileUploadClick, uploading = false, - selectedProviders = [], - onProvidersChange, + providerSelection, + onProviderSelectionChange, isMobile = false, onTreeItemsDrop, onPasteAsFile, @@ -653,12 +654,11 @@ export const WorkspaceInput: React.FC = ({
)} - {onProvidersChange && ( + {onProviderSelectionChange && providerSelection && ( )} diff --git a/src/pages/views/workspace/WorkspacePage.tsx b/src/pages/views/workspace/WorkspacePage.tsx index 01f965b..d333bd5 100644 --- a/src/pages/views/workspace/WorkspacePage.tsx +++ b/src/pages/views/workspace/WorkspacePage.tsx @@ -19,6 +19,9 @@ import { ToolActivityLog } from './ToolActivityLog'; import { UnifiedDataBar } from '../../../components/UnifiedDataBar'; import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar'; import api from '../../../api'; +import { _defaultProviderSelection, _toBackendProviders } from '../../../components/ProviderSelector'; +import type { ProviderSelection } from '../../../components/ProviderSelector'; +import { useBilling } from '../../../hooks/useBilling'; function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) { const [width, setWidth] = useState(initialWidth); @@ -81,7 +84,8 @@ export const WorkspacePage: React.FC = ({ persistentInstance const [udbTab, setUdbTab] = useState('chats'); const [selectedFileId, setSelectedFileId] = useState(null); const [pendingFiles, setPendingFiles] = useState([]); - const [selectedProviders, setSelectedProviders] = useState([]); + const [providerSelection, setProviderSelection] = useState(_defaultProviderSelection()); + const { allowedProviders } = useBilling(); const [isDragOver, setIsDragOver] = useState(false); const [draftAppend, setDraftAppend] = useState(''); const dragCounterRef = useRef(0); @@ -414,7 +418,8 @@ export const WorkspacePage: React.FC = ({ persistentInstance instanceId={instanceId} onSend={(prompt, fileIds, dataSourceIds, featureDataSourceIds, options) => { const allFileIds = [...new Set([...pendingFiles.map(f => f.fileId), ...(fileIds || [])])]; - workspace.sendMessage(prompt, allFileIds, dataSourceIds, selectedProviders, featureDataSourceIds, options); + const resolvedProviders = _toBackendProviders(providerSelection, allowedProviders); + workspace.sendMessage(prompt, allFileIds, dataSourceIds, resolvedProviders, featureDataSourceIds, options); setPendingFiles([]); }} isProcessing={workspace.isProcessing} @@ -426,8 +431,8 @@ export const WorkspacePage: React.FC = ({ persistentInstance onRemovePendingFile={_handleRemovePendingFile} onFileUploadClick={() => fileInputRef.current?.click()} uploading={fileOps.uploadingFile} - selectedProviders={selectedProviders} - onProvidersChange={setSelectedProviders} + providerSelection={providerSelection} + onProviderSelectionChange={setProviderSelection} isMobile={isMobile} onTreeItemsDrop={_handleTreeItemsDrop} onPasteAsFile={_uploadAndAttach}