@@ -213,12 +234,22 @@ function Login() {
- Du hast noch keinen Konto?
+ Du hast noch kein Konto?
+
+
navigate("/register", { state: location.state })}
+ type="button"
+ className={styles.ctaPrimary}
+ onClick={() => navigate('/register?type=personal', { state: location.state })}
>
- Registrieren
+ Kostenlos testen
+
+ navigate('/register?type=company', { state: location.state })}
+ >
+ Für Unternehmen
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() {
E-Mail
+ {registrationType === 'company' && (
+
- {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}
+
+
+
onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
+ disabled={isProcessing}
+ >
+ {isProcessing
+ ? '...'
+ : (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
+
+
+ ))}
+
+ )}
+
+ {activeInstances.length === 0 && (
+
+
+
+ {language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available'}
+
+
+ )}
- {feature.isActive ? (
-
onDeactivate(feature.featureCode)}
- disabled={isProcessing}
- >
- {isProcessing
- ? (language === 'de' ? 'Wird deaktiviert...' : 'Deactivating...')
- : (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
-
- ) : (
-
onActivate(feature.featureCode)}
- disabled={isProcessing || !feature.canActivate}
- >
- {isProcessing
- ? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
- : (language === 'de' ? 'Aktivieren' : language === 'fr' ? 'Activer' : 'Activate')}
-
+ {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 && (
+
setSelectedMandateId(e.target.value)}
+ disabled={isProcessing}
+ >
+
+ {language === 'de' ? '-- Mandant waehlen --' : language === 'fr' ? '-- Choisir mandat --' : '-- Select mandate --'}
+
+ {mandates.map((m) => (
+ {m.label || m.name}
+ ))}
+
+ )}
+
+ {isProcessing
+ ? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
+ : (language === 'de' ? 'Aktivieren' : language === 'fr' ? 'Activer' : 'Activate')}
+
+ >
)}
@@ -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 && (
+
+ setUdbCollapsed(v => !v)}
+ title={udbCollapsed ? 'Seitenleiste einblenden' : 'Seitenleiste ausblenden'}
+ >
+ {udbCollapsed ? '\u25B6' : '\u25C0'}
+
+ {!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 => (
setActiveTab(tab)}
>
- {_tabLabel(tab, coach, documents)}
+ {_tabLabel(tab, coach)}
))}
@@ -546,40 +522,6 @@ export const CommcoachDossierView: React.FC = () => {
)}
- {/* ============================================================ */}
- {/* DOCUMENTS TAB */}
- {/* ============================================================ */}
- {activeTab === 'documents' && (
-
-
-
- {uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
-
-
-
- {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}
}
-
-
- handleDownloadDocument(doc)} disabled={!doc.fileRef}>Download
- handleDeleteDocument(doc.id)}>x
-
-
- ))}
-
- )}
-
- )}
>)}
{/* #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)}
+
+
+
+ { e.stopPropagation(); _handleRetrigger(src.fileId); }}
+ style={{ fontSize: '0.8rem', padding: '4px 10px', borderRadius: 6, border: '1px solid var(--border-color, #d1d5db)', background: 'transparent', cursor: 'pointer' }}
+ >
+ Erneut neutralisieren
+
+
+ {selectedSource === src.fileId ? '\u25BC' : '\u25B6'}
+
+
+
+ ))}
+
+ )}
+
+ {selectedSource && mappings.length > 0 && (
+
+
+ Platzhalter-Mappings ({mappings.length})
+
+
+ {mappings.map((m) => (
+
+ {m.placeholder}
+ {'\u2192'}
+ {m.originalText}
+ {m.patternType}
+ _handleDeleteMapping(m.id)}
+ style={{ color: '#ef4444', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.9rem', padding: '2px 6px' }}
+ title="Mapping löschen"
+ >
+ {'\u00D7'}
+
+
+ ))}
+
+
+ )}
+
+ {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 = (
- <>
-
- setLeftTab('conversations')}>Chats
- setLeftTab('files')}>Files
- setLeftTab('datasources')}>Sources
-
+ 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
)}
+