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' && ( + + )}
);