unified data - step 1

This commit is contained in:
ValueOn AG 2026-03-24 14:16:45 +01:00
parent cc8a699e58
commit bc091c399c
30 changed files with 1876 additions and 210 deletions

View file

@ -27,6 +27,8 @@ export interface RegisterData {
language?: string; language?: string;
enabled?: boolean; enabled?: boolean;
privilege?: string; privilege?: string;
registrationType?: 'personal' | 'company';
companyName?: string;
} }
export interface RegisterRequest { export interface RegisterRequest {
@ -40,6 +42,8 @@ export interface RegisterRequest {
authenticationAuthority: string; authenticationAuthority: string;
}; };
frontendUrl: string; frontendUrl: string;
registrationType?: string;
companyName?: string;
} }
export interface PasswordResetRequestResponse { export interface PasswordResetRequestResponse {
@ -172,7 +176,9 @@ export async function registerApi(registerData: RegisterData): Promise<RegisterR
privilege: registerData.privilege || 'user', privilege: registerData.privilege || 'user',
authenticationAuthority: 'local' authenticationAuthority: 'local'
}, },
frontendUrl: window.location.origin frontendUrl: window.location.origin,
registrationType: registerData.registrationType,
companyName: registerData.companyName,
}; };
// Prepare headers with CSRF token if available // Prepare headers with CSRF token if available

View file

@ -7,14 +7,21 @@
import api from '../api'; import api from '../api';
export interface StoreFeatureInstance {
instanceId: string;
mandateId: string;
mandateName: string;
label: string;
isActive: boolean;
}
export interface StoreFeature { export interface StoreFeature {
featureCode: string; featureCode: string;
label: Record<string, string>; label: Record<string, string>;
icon: string; icon: string;
description: Record<string, string>; description: Record<string, string>;
isActive: boolean; instances: StoreFeatureInstance[];
canActivate: boolean; canActivate: boolean;
instanceId: string | null;
} }
export interface StoreActivateResponse { export interface StoreActivateResponse {
@ -31,17 +38,44 @@ export interface StoreDeactivateResponse {
deactivated: boolean; 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<StoreFeature[]> { export async function fetchStoreFeatures(): Promise<StoreFeature[]> {
const response = await api.get<StoreFeature[]>('/api/store/features'); const response = await api.get<StoreFeature[]>('/api/store/features');
return response.data; return response.data;
} }
export async function activateStoreFeature(featureCode: string): Promise<StoreActivateResponse> { export async function fetchUserMandates(): Promise<UserMandate[]> {
const response = await api.post<StoreActivateResponse>('/api/store/activate', { featureCode }); const response = await api.get<UserMandate[]>('/api/store/mandates');
return response.data; return response.data;
} }
export async function deactivateStoreFeature(featureCode: string): Promise<StoreDeactivateResponse> { export async function fetchSubscriptionInfo(mandateId?: string): Promise<SubscriptionInfo> {
const response = await api.post<StoreDeactivateResponse>('/api/store/deactivate', { featureCode }); const params = mandateId ? { mandateId } : {};
const response = await api.get<SubscriptionInfo>('/api/store/subscription-info', { params });
return response.data;
}
export async function activateStoreFeature(featureCode: string, mandateId?: string): Promise<StoreActivateResponse> {
const response = await api.post<StoreActivateResponse>('/api/store/activate', { featureCode, mandateId });
return response.data;
}
export async function deactivateStoreFeature(featureCode: string, mandateId: string, instanceId: string): Promise<StoreDeactivateResponse> {
const response = await api.post<StoreDeactivateResponse>('/api/store/deactivate', { featureCode, mandateId, instanceId });
return response.data; return response.data;
} }

View file

@ -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<OnboardingAssistantProps> = ({
instanceId,
mandateId,
featureCode,
onDismiss,
}) => {
const navigate = useNavigate();
const [dismissed, setDismissed] = useState(false);
const [steps, setSteps] = useState<OnboardingStep[]>([]);
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 (
<div style={{
padding: 16, margin: 16, borderRadius: 12,
border: '1px solid var(--border-color, #e5e7eb)',
background: 'linear-gradient(135deg, var(--bg-primary, #fff) 0%, #eef2ff 100%)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 12 }}>
<div>
<h3 style={{ margin: 0, fontSize: '1rem' }}>Willkommen bei PowerOn</h3>
<p style={{ margin: '4px 0 0', fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
{completedCount} von {steps.length} Schritten abgeschlossen
</p>
</div>
<button
onClick={_handleDismiss}
style={{ border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '1.1rem', color: '#9ca3af', padding: '2px 6px' }}
>
{'\u00D7'}
</button>
</div>
<div style={{ display: 'flex', gap: 4, marginBottom: 16, height: 4 }}>
{steps.map((step) => (
<div
key={step.id}
style={{
flex: 1, borderRadius: 2, height: 4,
background: step.completed ? 'var(--accent, #4f46e5)' : 'var(--border-color, #e5e7eb)',
transition: 'background 0.3s',
}}
/>
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{steps.map((step) => (
<div
key={step.id}
style={{
display: 'flex', alignItems: 'center', gap: 10, padding: '8px 12px',
borderRadius: 8, background: step.completed ? 'transparent' : 'var(--bg-primary, #fff)',
border: step.completed ? 'none' : '1px solid var(--border-color, #e5e7eb)',
opacity: step.completed ? 0.6 : 1,
cursor: step.action ? 'pointer' : 'default',
}}
onClick={step.action}
>
<span style={{ fontSize: '1.1rem', flexShrink: 0, width: 24, textAlign: 'center' }}>
{step.completed ? '\u2713' : '\u25CB'}
</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, fontSize: '0.85rem', textDecoration: step.completed ? 'line-through' : 'none' }}>
{step.label}
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text-secondary, #6b7280)' }}>
{step.description}
</div>
</div>
{step.action && !step.completed && (
<span style={{ fontSize: '0.8rem', color: 'var(--accent, #4f46e5)', fontWeight: 500 }}>{'\u2192'}</span>
)}
</div>
))}
</div>
</div>
);
};
export default OnboardingAssistant;

View file

@ -0,0 +1,119 @@
import React, { useState } from 'react';
import api from '../api';
interface OnboardingWizardProps {
onComplete: () => void;
onDismiss: () => void;
}
const OnboardingWizard: React.FC<OnboardingWizardProps> = ({ onComplete, onDismiss }) => {
const [mandateType, setMandateType] = useState<'personal' | 'company'>('personal');
const [companyName, setCompanyName] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 9999,
}}>
<div style={{
background: 'var(--bg-primary, #fff)', borderRadius: '12px', padding: '32px',
maxWidth: '480px', width: '90%', boxShadow: '0 8px 32px rgba(0,0,0,0.2)',
}}>
<h2 style={{ margin: '0 0 8px', fontSize: '1.5rem' }}>Willkommen bei PowerOn</h2>
<p style={{ color: 'var(--text-secondary, #666)', margin: '0 0 24px' }}>
Wie möchtest du PowerOn nutzen?
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', marginBottom: '20px' }}>
<label style={{
display: 'flex', alignItems: 'center', gap: '12px', padding: '16px',
border: mandateType === 'personal' ? '2px solid var(--accent, #4f46e5)' : '2px solid var(--border, #e5e7eb)',
borderRadius: '8px', cursor: 'pointer',
}}>
<input type="radio" name="type" checked={mandateType === 'personal'}
onChange={() => setMandateType('personal')} />
<div>
<strong>Persönlich</strong>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
7 Tage kostenlos testen, danach flexibel upgraden
</div>
</div>
</label>
<label style={{
display: 'flex', alignItems: 'center', gap: '12px', padding: '16px',
border: mandateType === 'company' ? '2px solid var(--accent, #4f46e5)' : '2px solid var(--border, #e5e7eb)',
borderRadius: '8px', cursor: 'pointer',
}}>
<input type="radio" name="type" checked={mandateType === 'company'}
onChange={() => setMandateType('company')} />
<div>
<strong>Unternehmen</strong>
<div style={{ fontSize: '0.85rem', color: 'var(--text-secondary, #666)' }}>
Team-Workspace mit Mandanten-Verwaltung
</div>
</div>
</label>
</div>
{mandateType === 'company' && (
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '6px', fontWeight: 500 }}>
Firmenname
</label>
<input
type="text" value={companyName}
onChange={(e) => 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',
}}
/>
</div>
)}
{error && <p style={{ color: '#ef4444', margin: '0 0 16px' }}>{error}</p>}
<div style={{ display: 'flex', gap: '12px', justifyContent: 'flex-end' }}>
<button onClick={onDismiss} style={{
padding: '10px 20px', borderRadius: '6px', border: '1px solid var(--border, #d1d5db)',
background: 'transparent', cursor: 'pointer',
}}>
Später
</button>
<button onClick={_handleSubmit} disabled={loading || (mandateType === 'company' && !companyName.trim())}
style={{
padding: '10px 20px', borderRadius: '6px', border: 'none',
background: 'var(--accent, #4f46e5)', color: '#fff', cursor: 'pointer',
opacity: loading ? 0.6 : 1,
}}>
{loading ? 'Wird eingerichtet...' : 'Loslegen'}
</button>
</div>
</div>
</div>
);
};
export default OnboardingWizard;

View file

@ -15,6 +15,12 @@ export interface MessageDocument {
taskNumber: number; taskNumber: number;
actionNumber: number; actionNumber: number;
actionId: string; actionId: string;
documentName?: string;
validationMetadata?: {
neutralized?: boolean;
skipped?: boolean;
[key: string]: unknown;
};
} }
/** /**

View file

@ -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;
}
}

View file

@ -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<ChatsTabProps> = ({ context, onSelectChat, onDragStart }) => {
const [groups, setGroups] = useState<ChatGroup[]>([]);
const [flatMode, setFlatMode] = useState(false);
const [search, setSearch] = useState('');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(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<string, ChatGroup>();
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 <div className={styles.loading}>Lade Chats...</div>;
return (
<div className={styles.chatsTab}>
<div className={styles.toolbar}>
<input
className={styles.search}
type="text"
placeholder="Suchen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<button
className={`${styles.modeToggle} ${flatMode ? styles.modeActive : ''}`}
onClick={() => setFlatMode(!flatMode)}
title={flatMode ? 'Baumansicht' : 'Listenansicht'}
>
{flatMode ? '\uD83C\uDF33' : '\uD83D\uDCCB'}
</button>
</div>
{flatMode ? (
<div className={styles.flatList}>
{_allChats.map((chat) => (
<div
key={chat.id}
className={styles.chatItem}
onClick={() => 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);
}}
>
<span className={styles.chatLabel}>{chat.label}</span>
{chat.updatedAt && (
<span className={styles.chatDate}>
{new Date(chat.updatedAt).toLocaleDateString()}
</span>
)}
</div>
))}
</div>
) : (
<div className={styles.tree}>
{_filteredGroups.map((group) => (
<div key={group.featureInstanceId} className={styles.treeGroup}>
<div
className={`${styles.treeGroupHeader} ${
group.featureInstanceId === context.instanceId ? styles.treeGroupCurrent : ''
}`}
onClick={() => _toggleGroup(group.featureInstanceId)}
>
<span className={styles.treeArrow}>
{expandedGroups.has(group.featureInstanceId) ? '\u25BC' : '\u25B6'}
</span>
<span className={styles.treeGroupLabel}>{group.featureLabel}</span>
<span className={styles.treeGroupCount}>{group.chats.length}</span>
</div>
{expandedGroups.has(group.featureInstanceId) && (
<div className={styles.treeChildren}>
{group.chats.map((chat) => (
<div
key={chat.id}
className={styles.chatItem}
onClick={() => 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);
}}
>
<span className={styles.chatLabel}>{chat.label}</span>
</div>
))}
</div>
)}
</div>
))}
</div>
)}
</div>
);
};
export default ChatsTab;

View file

@ -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);
}
}

View file

@ -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<string, string> = {
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<FilesTabProps> = ({ context, onFileSelect }) => {
const [files, setFiles] = useState<FileEntry[]>([]);
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 <div className={styles.loading}>Lade Dateien...</div>;
return (
<div className={styles.filesTab}>
{files.length === 0 ? (
<div className={styles.empty}>Keine Dateien vorhanden</div>
) : (
<div className={styles.fileList}>
{files.map((file) => (
<div
key={file.id}
className={styles.fileRow}
onClick={() => onFileSelect?.(file.id)}
>
<span className={styles.fileName}>{file.fileName}</span>
<div className={styles.fileIcons}>
<button
className={styles.scopeIcon}
onClick={(e) => {
e.stopPropagation();
_cycleScope(file);
}}
title={`Scope: ${file.scope} (klicken zum Wechseln)`}
>
{_SCOPE_ICONS[file.scope] || '\uD83D\uDC64'}
</button>
<button
className={`${styles.neutralizeIcon} ${
file.neutralize ? styles.neutralizeActive : ''
}`}
onClick={(e) => {
e.stopPropagation();
_toggleNeutralize(file);
}}
title={file.neutralize ? 'Neutralisierung aktiv' : 'Neutralisierung aus'}
>
\uD83D\uDD12
</button>
</div>
</div>
))}
</div>
)}
<div className={styles.legend}>
<span>{'\uD83D\uDC64'} Pers\u00F6nlich</span>
<span>{'\uD83D\uDC65'} Instanz</span>
<span>{'\uD83C\uDFE2'} Mandant</span>
<span>{'\uD83D\uDD12'} Neutralisiert</span>
</div>
</div>
);
};
export default FilesTab;

View file

@ -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;
}

View file

@ -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<SourcesTabProps> = ({ context, renderDataSourcePanel }) => {
if (renderDataSourcePanel) {
return <div className={styles.sourcesTab}>{renderDataSourcePanel(context.instanceId)}</div>;
}
return (
<div className={styles.sourcesTab}>
<div className={styles.placeholder}>
Datenquellen werden \u00FCber den Workspace verwaltet.
</div>
</div>
);
};
export default SourcesTab;

View file

@ -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);
}
}

View file

@ -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<UdbTab, Record<string, string>> = {
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<UnifiedDataBarProps> = ({
context,
activeTab: controlledTab,
onTabChange,
renderChats,
renderFiles,
renderSources,
className,
}) => {
const [internalTab, setInternalTab] = useState<UdbTab>('chats');
const currentTab = controlledTab ?? internalTab;
const _handleTabChange = (tab: UdbTab) => {
setInternalTab(tab);
onTabChange?.(tab);
};
return (
<div className={`${styles.udb} ${className || ''}`}>
<div className={styles.tabBar}>
{(['chats', 'files', 'sources'] as UdbTab[]).map((tab) => (
<button
key={tab}
className={`${styles.tab} ${currentTab === tab ? styles.tabActive : ''}`}
onClick={() => _handleTabChange(tab)}
>
{_TAB_LABELS[tab].de}
</button>
))}
</div>
<div className={styles.tabContent}>
{currentTab === 'chats' && renderChats?.(context)}
{currentTab === 'files' && renderFiles?.(context)}
{currentTab === 'sources' && renderSources?.(context)}
</div>
</div>
);
};
export default UnifiedDataBar;

View file

@ -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';

View file

@ -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]);
}

View file

@ -279,6 +279,7 @@ export function useRegister() {
interface GoogleAuthResponse { interface GoogleAuthResponse {
accessToken: string; accessToken: string;
tokenType: string; tokenType: string;
isNewUser?: boolean;
user: { user: {
username: string; username: string;
email: string; email: string;

View file

@ -11,40 +11,64 @@ import {
fetchStoreFeatures, fetchStoreFeatures,
activateStoreFeature, activateStoreFeature,
deactivateStoreFeature, deactivateStoreFeature,
fetchUserMandates,
fetchSubscriptionInfo,
type StoreFeature, type StoreFeature,
type UserMandate,
type SubscriptionInfo,
} from '../api/storeApi'; } from '../api/storeApi';
import { useFeatureStore } from '../stores/featureStore'; import { useFeatureStore } from '../stores/featureStore';
interface UseStoreReturn { interface UseStoreReturn {
features: StoreFeature[]; features: StoreFeature[];
mandates: UserMandate[];
subscriptionInfo: SubscriptionInfo | null;
loading: boolean; loading: boolean;
actionLoading: string | null; actionLoading: string | null;
error: string | null; error: string | null;
loadStore: () => Promise<void>; loadStore: () => Promise<void>;
activate: (featureCode: string) => Promise<void>; loadSubscriptionInfo: (mandateId?: string) => Promise<void>;
deactivate: (featureCode: string) => Promise<void>; activate: (featureCode: string, mandateId?: string) => Promise<void>;
deactivate: (featureCode: string, mandateId: string, instanceId: string) => Promise<void>;
} }
export function useStore(): UseStoreReturn { export function useStore(): UseStoreReturn {
const [features, setFeatures] = useState<StoreFeature[]>([]); const [features, setFeatures] = useState<StoreFeature[]>([]);
const [mandates, setMandates] = useState<UserMandate[]>([]);
const [subscriptionInfo, setSubscriptionInfo] = useState<SubscriptionInfo | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null); const [actionLoading, setActionLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const featureStore = useFeatureStore(); const featureStore = useFeatureStore();
const loadSubscriptionInfo = useCallback(async (mandateId?: string) => {
try {
const info = await fetchSubscriptionInfo(mandateId);
setSubscriptionInfo(info);
} catch {
// non-critical
}
}, []);
const loadStore = useCallback(async () => { const loadStore = useCallback(async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const data = await fetchStoreFeatures(); const [data, userMandates] = await Promise.all([
fetchStoreFeatures(),
fetchUserMandates(),
]);
setFeatures(data); setFeatures(data);
setMandates(userMandates);
const firstMandateId = userMandates.length > 0 ? userMandates[0].id : undefined;
await loadSubscriptionInfo(firstMandateId);
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Failed to load store'; const msg = err instanceof Error ? err.message : 'Failed to load store';
setError(msg); setError(msg);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [loadSubscriptionInfo]);
useEffect(() => { useEffect(() => {
loadStore(); loadStore();
@ -56,11 +80,11 @@ export function useStore(): UseStoreReturn {
await loadStore(); await loadStore();
}, [featureStore, loadStore]); }, [featureStore, loadStore]);
const activate = useCallback(async (featureCode: string) => { const activate = useCallback(async (featureCode: string, mandateId?: string) => {
setActionLoading(featureCode); setActionLoading(featureCode);
setError(null); setError(null);
try { try {
await activateStoreFeature(featureCode); await activateStoreFeature(featureCode, mandateId);
await _refreshAfterAction(); await _refreshAfterAction();
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Activation failed'; const msg = err instanceof Error ? err.message : 'Activation failed';
@ -70,11 +94,11 @@ export function useStore(): UseStoreReturn {
} }
}, [_refreshAfterAction]); }, [_refreshAfterAction]);
const deactivate = useCallback(async (featureCode: string) => { const deactivate = useCallback(async (featureCode: string, mandateId: string, instanceId: string) => {
setActionLoading(featureCode); setActionLoading(featureCode);
setError(null); setError(null);
try { try {
await deactivateStoreFeature(featureCode); await deactivateStoreFeature(featureCode, mandateId, instanceId);
await _refreshAfterAction(); await _refreshAfterAction();
} catch (err: unknown) { } catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Deactivation failed'; const msg = err instanceof Error ? err.message : 'Deactivation failed';
@ -84,7 +108,7 @@ export function useStore(): UseStoreReturn {
} }
}, [_refreshAfterAction]); }, [_refreshAfterAction]);
return { features, loading, actionLoading, error, loadStore, activate, deactivate }; return { features, mandates, subscriptionInfo, loading, actionLoading, error, loadStore, loadSubscriptionInfo, activate, deactivate };
} }
export default useStore; export default useStore;

View file

@ -242,6 +242,50 @@
text-decoration: underline; 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 { button:disabled {
opacity: 0.7; opacity: 0.7;
cursor: not-allowed; cursor: not-allowed;

View file

@ -5,6 +5,7 @@ import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText } from 'react-icons/fa';
import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication'; import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils'; import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { PENDING_INVITATION_KEY } from './InvitePage'; import { PENDING_INVITATION_KEY } from './InvitePage';
import OnboardingWizard from '../components/OnboardingWizard';
import styles from './Login.module.css'; import styles from './Login.module.css';
@ -21,6 +22,7 @@ function Login() {
const { login, error: loginError, isLoading: isLoginLoading } = useAuth(); const { login, error: loginError, isLoading: isLoginLoading } = useAuth();
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth(); const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth(); const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
const [showOnboardingWizard, setShowOnboardingWizard] = useState(false);
// Check for pending invitation // Check for pending invitation
const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY); const pendingInvitationToken = localStorage.getItem(PENDING_INVITATION_KEY);
@ -84,6 +86,10 @@ function Login() {
console.log("Attempting Google login..."); console.log("Attempting Google login...");
const response = await loginWithGoogle(); const response = await loginWithGoogle();
console.log("Google login successful:", response); console.log("Google login successful:", response);
if (response?.isNewUser) {
setShowOnboardingWizard(true);
return;
}
handleSuccessfulLogin(); handleSuccessfulLogin();
} catch (error) { } catch (error) {
console.error("Google login failed:", error); console.error("Google login failed:", error);
@ -104,6 +110,21 @@ function Login() {
} }
}; };
if (showOnboardingWizard) {
return (
<OnboardingWizard
onComplete={() => {
setShowOnboardingWizard(false);
handleSuccessfulLogin();
}}
onDismiss={() => {
setShowOnboardingWizard(false);
handleSuccessfulLogin();
}}
/>
);
}
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.mainContent}> <div className={styles.mainContent}>
@ -213,12 +234,22 @@ function Login() {
</button> </button>
<div className={styles.registerLink}> <div className={styles.registerLink}>
<span>Du hast noch keinen Konto?</span> <span>Du hast noch kein Konto?</span>
</div>
<div className={styles.ctaSection}>
<button <button
className={styles.textButton} type="button"
onClick={() => navigate("/register", { state: location.state })} className={styles.ctaPrimary}
onClick={() => navigate('/register?type=personal', { state: location.state })}
> >
Registrieren Kostenlos testen
</button>
<button
type="button"
className={styles.ctaSecondary}
onClick={() => navigate('/register?type=company', { state: location.state })}
>
Für Unternehmen
</button> </button>
</div> </div>
</div> </div>

View file

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; 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 { FaEnvelopeOpenText } from 'react-icons/fa';
import styles from './Register.module.css'; import styles from './Register.module.css';
@ -27,6 +27,10 @@ function Register() {
email: invitationEmail, email: invitationEmail,
fullName: '' 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<string | null>(null); const [validationError, setValidationError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null); const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [usernameFocused, setUsernameFocused] = useState(false); const [usernameFocused, setUsernameFocused] = useState(false);
@ -40,11 +44,13 @@ function Register() {
// Set page title and generate CSRF token // Set page title and generate CSRF token
useEffect(() => { 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 // Generate CSRF token for new security implementation
generateAndStoreCSRFToken(); generateAndStoreCSRFToken();
}, []); }, [registrationType]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target; const { name, value } = e.target;
@ -70,6 +76,11 @@ function Register() {
return false; return false;
} }
if (registrationType === 'company' && !companyName.trim()) {
setValidationError('Bitte geben Sie einen Firmennamen ein.');
return false;
}
return true; return true;
}; };
@ -97,7 +108,7 @@ function Register() {
} }
// Username is available, proceed with registration (no password - magic link flow) // 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 // 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.'; 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() {
<label className={emailFocused || formData.email ? styles.focusedLabel : styles.label}>E-Mail</label> <label className={emailFocused || formData.email ? styles.focusedLabel : styles.label}>E-Mail</label>
</div> </div>
{registrationType === 'company' && (
<div className={styles.floatingLabelInput}>
<input
type="text"
name="companyName"
placeholder=" "
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
onFocus={() => setCompanyNameFocused(true)}
onBlur={() => setCompanyNameFocused(false)}
className={`${styles.input} ${companyNameFocused || companyName ? styles.focused : ''}`}
/>
<label className={companyNameFocused || companyName ? styles.focusedLabel : styles.label}>Firmenname *</label>
</div>
)}
<div className={styles.floatingLabelInput}> <div className={styles.floatingLabelInput}>
<input <input
type="text" type="text"
@ -221,7 +248,7 @@ function Register() {
onClick={handleSubmit} onClick={handleSubmit}
disabled={isLoading || isChecking} disabled={isLoading || isChecking}
> >
{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'}
</button> </button>
</> </>
)} )}

View file

@ -29,6 +29,52 @@
font-size: 0.9375rem; 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 */
.grid { .grid {
display: grid; display: grid;
@ -120,6 +166,49 @@
background: currentColor; 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 */ /* Actions */
.cardActions { .cardActions {
padding-top: 0.5rem; padding-top: 0.5rem;
@ -243,17 +332,35 @@
border-top-color: var(--border-dark, #333); 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); border-color: var(--border-dark, #444);
color: var(--text-secondary-dark, #aaa); 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); border-color: var(--error-color-dark, #f87171);
color: var(--error-color-dark, #f87171); color: var(--error-color-dark, #f87171);
background: rgba(248, 113, 113, 0.1); 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 { :global(.dark-theme) .error {
background: var(--error-bg-dark, #450a0a); background: var(--error-bg-dark, #450a0a);
border-color: var(--error-border-dark, #991b1b); border-color: var(--error-border-dark, #991b1b);

View file

@ -6,11 +6,11 @@
* and users get their own FeatureAccess + user-role upon activation. * 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 { FaCogs, FaComments, FaHeadset, FaProjectDiagram } from 'react-icons/fa';
import { useLanguage } from '../providers/language/LanguageContext'; import { useLanguage } from '../providers/language/LanguageContext';
import { useStore } from '../hooks/useStore'; import { useStore } from '../hooks/useStore';
import type { StoreFeature } from '../api/storeApi'; import type { StoreFeature, UserMandate } from '../api/storeApi';
import styles from './Store.module.css'; import styles from './Store.module.css';
const FEATURE_ICONS: Record<string, React.ReactNode> = { const FEATURE_ICONS: Record<string, React.ReactNode> = {
@ -62,23 +62,39 @@ function _getDescription(featureCode: string, lang: string): string {
interface FeatureCardProps { interface FeatureCardProps {
feature: StoreFeature; feature: StoreFeature;
language: string; language: string;
mandates: UserMandate[];
actionLoading: string | null; actionLoading: string | null;
onActivate: (code: string) => void; onActivate: (code: string, mandateId?: string) => void;
onDeactivate: (code: string) => void; onDeactivate: (code: string, mandateId: string, instanceId: string) => void;
} }
const FeatureCard: React.FC<FeatureCardProps> = ({ const FeatureCard: React.FC<FeatureCardProps> = ({
feature, feature,
language, language,
mandates,
actionLoading, actionLoading,
onActivate, onActivate,
onDeactivate, onDeactivate,
}) => { }) => {
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
const isProcessing = actionLoading === feature.featureCode; const isProcessing = actionLoading === feature.featureCode;
const icon = FEATURE_ICONS[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 ( return (
<div className={`${styles.card} ${feature.isActive ? styles.cardActive : ''}`}> <div className={`${styles.card} ${hasActive ? styles.cardActive : ''}`}>
<div className={styles.cardHeader}> <div className={styles.cardHeader}>
{icon && <span className={styles.cardIcon}>{icon}</span>} {icon && <span className={styles.cardIcon}>{icon}</span>}
<h3 className={styles.cardTitle}> <h3 className={styles.cardTitle}>
@ -92,36 +108,76 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
</p> </p>
</div> </div>
<div> {activeInstances.length > 0 && (
<span className={`${styles.statusBadge} ${feature.isActive ? styles.statusActive : styles.statusInactive}`}> <div className={styles.instanceList}>
{activeInstances.map((inst) => (
<div key={inst.instanceId} className={styles.instanceRow}>
<div className={styles.instanceInfo}>
<span className={`${styles.statusBadge} ${styles.statusActive}`}>
<span className={styles.statusDot} /> <span className={styles.statusDot} />
{feature.isActive {inst.mandateName || inst.label}
? (language === 'de' ? 'Aktiv' : language === 'fr' ? 'Actif' : 'Active')
: (language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available')}
</span> </span>
</div> </div>
<div className={styles.cardActions}>
{feature.isActive ? (
<button <button
className={styles.deactivateButton} className={styles.deactivateButtonSmall}
onClick={() => onDeactivate(feature.featureCode)} onClick={() => onDeactivate(feature.featureCode, inst.mandateId, inst.instanceId)}
disabled={isProcessing} disabled={isProcessing}
> >
{isProcessing {isProcessing
? (language === 'de' ? 'Wird deaktiviert...' : 'Deactivating...') ? '...'
: (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')} : (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
</button> </button>
) : ( </div>
))}
</div>
)}
{activeInstances.length === 0 && (
<div>
<span className={`${styles.statusBadge} ${styles.statusInactive}`}>
<span className={styles.statusDot} />
{language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available'}
</span>
</div>
)}
<div className={styles.cardActions}>
{feature.canActivate && (
<>
{mandates.length === 0 && (
<p className={styles.mandateHint}>
{language === 'de'
? 'Ein persoenliches Konto wird automatisch erstellt.'
: language === 'fr'
? 'Un compte personnel sera cree automatiquement.'
: 'A personal account will be created automatically.'}
</p>
)}
{needsMandateSelection && (
<select
className={styles.mandateSelect}
value={selectedMandateId}
onChange={(e) => setSelectedMandateId(e.target.value)}
disabled={isProcessing}
>
<option value="">
{language === 'de' ? '-- Mandant waehlen --' : language === 'fr' ? '-- Choisir mandat --' : '-- Select mandate --'}
</option>
{mandates.map((m) => (
<option key={m.id} value={m.id}>{m.label || m.name}</option>
))}
</select>
)}
<button <button
className={styles.activateButton} className={styles.activateButton}
onClick={() => onActivate(feature.featureCode)} onClick={_handleActivate}
disabled={isProcessing || !feature.canActivate} disabled={isProcessing || (needsMandateSelection && !selectedMandateId)}
> >
{isProcessing {isProcessing
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...') ? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
: (language === 'de' ? 'Aktivieren' : language === 'fr' ? 'Activer' : 'Activate')} : (language === 'de' ? 'Aktivieren' : language === 'fr' ? 'Activer' : 'Activate')}
</button> </button>
</>
)} )}
</div> </div>
</div> </div>
@ -130,7 +186,7 @@ const FeatureCard: React.FC<FeatureCardProps> = ({
const StorePage: React.FC = () => { const StorePage: React.FC = () => {
const { currentLanguage } = useLanguage(); const { currentLanguage } = useLanguage();
const { features, loading, actionLoading, error, activate, deactivate } = useStore(); const { features, mandates, subscriptionInfo, loading, actionLoading, error, activate, deactivate } = useStore();
return ( return (
<div className={styles.store}> <div className={styles.store}>
@ -145,6 +201,27 @@ const StorePage: React.FC = () => {
</p> </p>
</div> </div>
{subscriptionInfo && subscriptionInfo.plan && (
<div className={styles.subscriptionBanner}>
<span>Plan: <strong>{subscriptionInfo.plan}</strong></span>
{subscriptionInfo.maxFeatureInstances != null && (
<span className={styles.bannerSeparator}>
{currentLanguage === 'de' ? 'Instanzen' : 'Instances'}: {subscriptionInfo.currentFeatureInstances}/{subscriptionInfo.maxFeatureInstances}
</span>
)}
{subscriptionInfo.maxDataVolumeMB != null && (
<span className={styles.bannerSeparator}>
{currentLanguage === 'de' ? 'Speicher' : 'Storage'}: {subscriptionInfo.maxDataVolumeMB} MB
</span>
)}
{subscriptionInfo.status === 'TRIALING' && subscriptionInfo.trialEndsAt && (
<span className={styles.bannerSeparator}>
{currentLanguage === 'de' ? 'Trial endet' : 'Trial ends'}: {new Date(subscriptionInfo.trialEndsAt).toLocaleDateString()}
</span>
)}
</div>
)}
{error && <div className={styles.error}>{error}</div>} {error && <div className={styles.error}>{error}</div>}
{loading ? ( {loading ? (
@ -164,6 +241,7 @@ const StorePage: React.FC = () => {
key={feature.featureCode} key={feature.featureCode}
feature={feature} feature={feature}
language={currentLanguage} language={currentLanguage}
mandates={mandates}
actionLoading={actionLoading} actionLoading={actionLoading}
onActivate={activate} onActivate={activate}
onDeactivate={deactivate} onDeactivate={deactivate}

View file

@ -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 { .dossier {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: calc(100vh - 140px); flex: 1;
min-width: 0;
overflow: hidden; overflow: hidden;
} }

View file

@ -1,48 +1,53 @@
/** /**
* CommCoach Dossier View (Main View) * 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. * Voice first, always with text fallback.
* Files & Sources are provided via the shared UnifiedDataBar sidebar.
*/ */
import React, { useState, useRef, useCallback, useEffect } from 'react'; import React, { useState, useRef, useCallback, useEffect } from 'react';
import { useCommcoach } from '../../../hooks/useCommcoach'; import { useCommcoach } from '../../../hooks/useCommcoach';
import { type TtsEvent } from '../../../hooks/useTtsPlayback'; import { type TtsEvent } from '../../../hooks/useTtsPlayback';
import { useApiRequest } from '../../../hooks/useApi'; import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId, useMandateId } from '../../../hooks/useCurrentInstance';
import api from '../../../api';
import { import {
getDossierExportUrl, getSessionExportUrl, getDossierExportUrl, getSessionExportUrl,
getDocumentsApi, uploadDocumentApi, deleteDocumentApi,
getScoreHistoryApi, getPersonasApi, getScoreHistoryApi, getPersonasApi,
type CoachingDocument, type CoachingPersona, type CoachingPersona,
} from '../../../api/commcoachApi'; } from '../../../api/commcoachApi';
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll'; import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; 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 styles from './CommcoachDossierView.module.css';
import { useVoiceController } from './useVoiceController'; import { useVoiceController } from './useVoiceController';
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores' | 'documents'; type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
export const CommcoachDossierView: React.FC = () => { export const CommcoachDossierView: React.FC = () => {
const coach = useCommcoach(); const coach = useCommcoach();
const { request } = useApiRequest(); const { request } = useApiRequest();
const instanceId = useInstanceId(); const instanceId = useInstanceId();
const mandateId = useMandateId();
const [activeTab, setActiveTab] = useState<TabKey>('coaching'); const [activeTab, setActiveTab] = useState<TabKey>('coaching');
const [showNewContext, setShowNewContext] = useState(false); const [showNewContext, setShowNewContext] = useState(false);
const [newTitle, setNewTitle] = useState(''); const [newTitle, setNewTitle] = useState('');
const [newDescription, setNewDescription] = useState(''); const [newDescription, setNewDescription] = useState('');
const [newCategory, setNewCategory] = useState('custom'); const [newCategory, setNewCategory] = useState('custom');
const [udbCollapsed, setUdbCollapsed] = useState(false);
const [newTaskTitle, setNewTaskTitle] = useState(''); const [newTaskTitle, setNewTaskTitle] = useState('');
const [documents, setDocuments] = useState<CoachingDocument[]>([]);
const [uploading, setUploading] = useState(false);
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({}); const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
const [personas, setPersonas] = useState<CoachingPersona[]>([]); const [personas, setPersonas] = useState<CoachingPersona[]>([]);
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined); const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
const _udbContext: UdbContext | null = instanceId
? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId }
: null;
const inputRef = useRef<HTMLTextAreaElement>(null); const inputRef = useRef<HTMLTextAreaElement>(null);
const sendMessageRef = useRef(coach.sendMessage); const sendMessageRef = useRef(coach.sendMessage);
sendMessageRef.current = coach.sendMessage; sendMessageRef.current = coach.sendMessage;
@ -82,27 +87,14 @@ export const CommcoachDossierView: React.FC = () => {
} }
}, [coach.contexts, coach.selectedContextId, coach.selectContext]); }, [coach.contexts, coach.selectedContextId, coach.selectContext]);
// Load documents, scores, personas when context changes // Load scores, personas when context changes
useEffect(() => { useEffect(() => {
if (!instanceId || !coach.selectedContextId) return; if (!instanceId || !coach.selectedContextId) return;
getDocumentsApi(request, instanceId, coach.selectedContextId)
.then(d => setDocuments(d))
.catch(() => {});
getScoreHistoryApi(request, instanceId, coach.selectedContextId) getScoreHistoryApi(request, instanceId, coach.selectedContextId)
.then(h => setScoreHistory(h)) .then(h => setScoreHistory(h))
.catch(() => {}); .catch(() => {});
}, [instanceId, request, coach.selectedContextId]); }, [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(() => { useEffect(() => {
if (!instanceId) return; if (!instanceId) return;
getPersonasApi(request, instanceId) getPersonasApi(request, instanceId)
@ -144,46 +136,6 @@ export const CommcoachDossierView: React.FC = () => {
coach.selectContext(contextId, { skipSessionResume: true }); coach.selectContext(contextId, { skipSessionResume: true });
}, [coach]); }, [coach]);
const handleUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
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 () => { const handleAddTask = useCallback(async () => {
if (!newTaskTitle.trim()) return; if (!newTaskTitle.trim()) return;
await coach.addTask(newTaskTitle); await coach.addTask(newTaskTitle);
@ -195,6 +147,30 @@ export const CommcoachDossierView: React.FC = () => {
} }
return ( return (
<div className={styles.dossierLayout}>
{/* UDB Sidebar */}
{_udbContext && (
<div className={`${styles.udbSidebar} ${udbCollapsed ? styles.udbSidebarCollapsed : ''}`}>
<button
className={styles.udbToggle}
onClick={() => setUdbCollapsed(v => !v)}
title={udbCollapsed ? 'Seitenleiste einblenden' : 'Seitenleiste ausblenden'}
>
{udbCollapsed ? '\u25B6' : '\u25C0'}
</button>
{!udbCollapsed && (
<UnifiedDataBar
context={_udbContext}
activeTab="files"
renderChats={() => null}
renderFiles={(ctx) => <FilesTab context={ctx} />}
renderSources={(ctx) => <SourcesTab context={ctx} />}
/>
)}
</div>
)}
{/* Main Content */}
<div className={styles.dossier}> <div className={styles.dossier}>
{/* Context Selector */} {/* Context Selector */}
<div className={styles.contextSelector}> <div className={styles.contextSelector}>
@ -286,13 +262,13 @@ export const CommcoachDossierView: React.FC = () => {
{/* Tab Navigation */} {/* Tab Navigation */}
<div className={styles.tabs}> <div className={styles.tabs}>
{(['coaching', 'tasks', 'sessions', 'scores', 'documents'] as TabKey[]).map(tab => ( {(['coaching', 'tasks', 'sessions', 'scores'] as TabKey[]).map(tab => (
<button <button
key={tab} key={tab}
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`} className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
onClick={() => setActiveTab(tab)} onClick={() => setActiveTab(tab)}
> >
{_tabLabel(tab, coach, documents)} {_tabLabel(tab, coach)}
</button> </button>
))} ))}
</div> </div>
@ -546,40 +522,6 @@ export const CommcoachDossierView: React.FC = () => {
</div> </div>
)} )}
{/* ============================================================ */}
{/* DOCUMENTS TAB */}
{/* ============================================================ */}
{activeTab === 'documents' && (
<div className={styles.tabContent}>
<div className={styles.addTaskRow}>
<label className={styles.uploadLabel}>
{uploading ? 'Wird hochgeladen...' : 'Dokument hochladen'}
<input type="file" accept=".txt,.md,.pdf,.doc,.docx" onChange={handleUpload} disabled={uploading} style={{ display: 'none' }} />
</label>
</div>
{documents.length === 0 ? (
<div className={styles.emptyTab}>Keine Dokumente. Lade Dateien hoch oder bitte den Coach, eines zu erstellen.</div>
) : (
<div className={styles.documentList}>
{documents.map(doc => (
<div key={doc.id} className={styles.documentItem}>
<div className={styles.documentInfo}>
<div className={styles.documentName}>{doc.fileName}</div>
<div className={styles.documentMeta}>
{_formatFileSize(doc.fileSize)} | {doc.createdAt ? new Date(doc.createdAt).toLocaleDateString('de-CH') : ''}
</div>
{doc.summary && <div className={styles.documentSummary}>{doc.summary}</div>}
</div>
<div className={styles.documentActions}>
<button className={styles.btnExport} onClick={() => handleDownloadDocument(doc)} disabled={!doc.fileRef}>Download</button>
<button className={styles.taskDelete} onClick={() => handleDeleteDocument(doc.id)}>x</button>
</div>
</div>
))}
</div>
)}
</div>
)}
</>)} </>)}
{/* #region agent log */} {/* #region agent log */}
<div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}> <div style={{position:'fixed',bottom:0,right:0,zIndex:9999}}>
@ -595,6 +537,7 @@ export const CommcoachDossierView: React.FC = () => {
</div> </div>
{/* #endregion */} {/* #endregion */}
</div> </div>
</div>
); );
}; };
@ -607,13 +550,12 @@ function _categoryIcon(category: string): string {
return icons[category] || '*'; return icons[category] || '*';
} }
function _tabLabel(tab: TabKey, coach: any, documents: CoachingDocument[]): string { function _tabLabel(tab: TabKey, coach: any): string {
switch (tab) { switch (tab) {
case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching'; case 'coaching': return coach.session ? 'Coaching (aktiv)' : 'Coaching';
case 'tasks': return `Aufgaben (${coach.tasks.length})`; case 'tasks': return `Aufgaben (${coach.tasks.length})`;
case 'sessions': return `Sessions (${coach.sessions.length})`; case 'sessions': return `Sessions (${coach.sessions.length})`;
case 'scores': return `Bewertungen (${coach.scores.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); 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 { function _dimensionLabel(dim: string): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit', empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',

View file

@ -7,6 +7,7 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useApiRequest } from '../../../hooks/useApi'; import { useApiRequest } from '../../../hooks/useApi';
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
import api from '../../../api';
import { import {
getProfileApi, updateProfileApi, getProfileApi, updateProfileApi,
getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi, getVoiceLanguagesApi, getVoiceVoicesApi, testVoiceApi,
@ -14,6 +15,18 @@ import {
} from '../../../api/commcoachApi'; } from '../../../api/commcoachApi';
import styles from './CommcoachSettingsView.module.css'; import styles from './CommcoachSettingsView.module.css';
async function _syncSharedVoicePreferences(lang: string, voice?: string): Promise<void> {
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 = () => { export const CommcoachSettingsView: React.FC = () => {
const { request } = useApiRequest(); const { request } = useApiRequest();
const instanceId = useInstanceId(); const instanceId = useInstanceId();
@ -88,6 +101,9 @@ export const CommcoachSettingsView: React.FC = () => {
emailSummaryEnabled: emailEnabled, emailSummaryEnabled: emailEnabled,
}); });
setProfile(updated); setProfile(updated);
_syncSharedVoicePreferences(language, voiceId || undefined);
setSuccess('Einstellungen gespeichert'); setSuccess('Einstellungen gespeichert');
setTimeout(() => setSuccess(null), 3000); setTimeout(() => setSuccess(null), 3000);
} catch (err: any) { } catch (err: any) {

View file

@ -147,6 +147,32 @@ export const ChatStream: React.FC<ChatStreamProps> = ({
charCount={(msg as any)._audioCharCount} charCount={(msg as any)._audioCharCount}
/> />
)} )}
{msg.role === 'assistant' && msg.documents && msg.documents.length > 0 && (
<details className="sentDataDetails" style={{ marginTop: 8, fontSize: '0.8rem', borderTop: '1px solid var(--border-color, #e5e7eb)', paddingTop: 6 }}>
<summary style={{ cursor: 'pointer', color: 'var(--text-secondary, #6b7280)', fontWeight: 500, userSelect: 'none' }}>
Gesendete Daten ({msg.documents.length} {msg.documents.length === 1 ? 'Dokument' : 'Dokumente'})
</summary>
<div style={{ marginTop: 6, display: 'flex', flexDirection: 'column', gap: 4 }}>
{msg.documents.map((doc, idx) => (
<div key={doc.id || idx} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-hover, rgba(0,0,0,0.02))', borderRadius: 4 }}>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{doc.documentName || doc.fileName || `Dokument ${idx + 1}`}
</span>
{doc.validationMetadata?.neutralized && (
<span style={{ fontSize: '0.7rem', padding: '1px 6px', borderRadius: 10, background: '#dcfce7', color: '#166534' }}>
neutralisiert
</span>
)}
{doc.validationMetadata?.skipped && (
<span style={{ fontSize: '0.7rem', padding: '1px 6px', borderRadius: 10, background: '#fef2f2', color: '#991b1b' }}>
übersprungen
</span>
)}
</div>
))}
</div>
</details>
)}
</div> </div>
)} )}
</div> </div>

View file

@ -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<NeutralizationPanelProps> = ({ instanceId }) => {
const [sources, setSources] = useState<NeutralizationSource[]>([]);
const [selectedSource, setSelectedSource] = useState<string | null>(null);
const [mappings, setMappings] = useState<NeutralizationMapping[]>([]);
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<string, { bg: string; text: string }> = {
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 (
<span style={{ fontSize: '0.75rem', padding: '2px 8px', borderRadius: 10, background: c.bg, color: c.text }}>
{status}
</span>
);
};
if (loading) return <div style={{ padding: 16, textAlign: 'center', color: '#6b7280' }}>Lade Neutralisierungsdaten...</div>;
return (
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 16 }}>
<h3 style={{ margin: 0, fontSize: '1.1rem' }}>Neutralisierung</h3>
<p style={{ margin: 0, fontSize: '0.85rem', color: 'var(--text-secondary, #6b7280)' }}>
Übersicht aller Datenquellen mit Neutralisierung und deren Platzhalter-Mappings.
</p>
{sources.length === 0 ? (
<div style={{ padding: 24, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
Keine Datenquellen mit aktiver Neutralisierung.
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{sources.map((src) => (
<div
key={src.fileId}
style={{
padding: '12px 16px', borderRadius: 8,
border: selectedSource === src.fileId ? '2px solid var(--accent, #4f46e5)' : '1px solid var(--border-color, #e5e7eb)',
cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center',
}}
onClick={() => setSelectedSource(src.fileId === selectedSource ? null : src.fileId)}
>
<div>
<div style={{ fontWeight: 500, fontSize: '0.9rem' }}>{src.fileName}</div>
<div style={{ fontSize: '0.8rem', color: '#6b7280', marginTop: 2 }}>
{_statusBadge(src.neutralizationStatus)}
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<button
onClick={(e) => { 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
</button>
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>
{selectedSource === src.fileId ? '\u25BC' : '\u25B6'}
</span>
</div>
</div>
))}
</div>
)}
{selectedSource && mappings.length > 0 && (
<div style={{ border: '1px solid var(--border-color, #e5e7eb)', borderRadius: 8, overflow: 'hidden' }}>
<div style={{ padding: '8px 16px', background: 'var(--bg-hover, #f9fafb)', fontSize: '0.85rem', fontWeight: 500 }}>
Platzhalter-Mappings ({mappings.length})
</div>
<div style={{ maxHeight: 300, overflowY: 'auto' }}>
{mappings.map((m) => (
<div key={m.id} style={{ display: 'flex', alignItems: 'center', padding: '8px 16px', borderTop: '1px solid var(--border-color, #f3f4f6)', fontSize: '0.8rem', gap: 12 }}>
<span style={{ flex: 1, fontFamily: 'monospace', color: '#4f46e5' }}>{m.placeholder}</span>
<span style={{ color: '#9ca3af' }}>{'\u2192'}</span>
<span style={{ flex: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{m.originalText}</span>
<span style={{ fontSize: '0.7rem', color: '#9ca3af', flexShrink: 0 }}>{m.patternType}</span>
<button
onClick={() => _handleDeleteMapping(m.id)}
style={{ color: '#ef4444', border: 'none', background: 'transparent', cursor: 'pointer', fontSize: '0.9rem', padding: '2px 6px' }}
title="Mapping löschen"
>
{'\u00D7'}
</button>
</div>
))}
</div>
</div>
)}
{selectedSource && mappings.length === 0 && (
<div style={{ padding: 16, textAlign: 'center', color: '#9ca3af', fontSize: '0.85rem' }}>
Keine Mappings für diese Datenquelle.
</div>
)}
</div>
);
};
export default NeutralizationPanel;

View file

@ -263,7 +263,10 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
}, [onPasteAsFile]); }, [onPasteAsFile]);
const _handlePromptDragOver = useCallback((e: React.DragEvent) => { 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.preventDefault();
e.dataTransfer.dropEffect = 'copy'; e.dataTransfer.dropEffect = 'copy';
setTreeDropOver(true); setTreeDropOver(true);
@ -273,11 +276,22 @@ export const WorkspaceInput: React.FC<WorkspaceInputProps> = ({
const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []); const _handlePromptDragLeave = useCallback(() => setTreeDropOver(false), []);
const _handlePromptDrop = useCallback((e: React.DragEvent) => { 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'); const treeItemsJson = e.dataTransfer.getData('application/tree-items');
if (treeItemsJson && onTreeItemsDrop) { if (treeItemsJson && onTreeItemsDrop) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
setTreeDropOver(false);
const items: TreeItemDrop[] = JSON.parse(treeItemsJson); const items: TreeItemDrop[] = JSON.parse(treeItemsJson);
onTreeItemsDrop(items); onTreeItemsDrop(items);
} }

View file

@ -19,6 +19,9 @@ import { FileBrowser } from './FileBrowser';
import { DataSourcePanel } from './DataSourcePanel'; import { DataSourcePanel } from './DataSourcePanel';
import { FilePreview } from './FilePreview'; import { FilePreview } from './FilePreview';
import { ToolActivityLog } from './ToolActivityLog'; 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) { function _useResizable(initialWidth: number, minWidth: number, maxWidth: number) {
const [width, setWidth] = useState(initialWidth); const [width, setWidth] = useState(initialWidth);
@ -52,7 +55,6 @@ function _useResizable(initialWidth: number, minWidth: number, maxWidth: number)
return { width, onMouseDown: _onMouseDown }; return { width, onMouseDown: _onMouseDown };
} }
type LeftTab = 'conversations' | 'files' | 'datasources';
type RightTab = 'activity' | 'preview'; type RightTab = 'activity' | 'preview';
interface PendingFile { interface PendingFile {
@ -78,7 +80,6 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
const [rightCollapsed, setRightCollapsed] = useState(false); const [rightCollapsed, setRightCollapsed] = useState(false);
const _leftResize = _useResizable(280, 200, 450); const _leftResize = _useResizable(280, 200, 450);
const _rightResize = _useResizable(320, 200, 500); const _rightResize = _useResizable(320, 200, 500);
const [leftTab, setLeftTab] = useState<LeftTab>('conversations');
const [rightTab, setRightTab] = useState<RightTab>('activity'); const [rightTab, setRightTab] = useState<RightTab>('activity');
const [selectedFileId, setSelectedFileId] = useState<string | null>(null); const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]); const [pendingFiles, setPendingFiles] = useState<PendingFile[]>([]);
@ -210,43 +211,42 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
textTransform: 'uppercase' as const, textTransform: 'uppercase' as const,
}); });
const _leftPanelBody = ( const _udbContext: UdbContext = {
<> instanceId: instanceId,
<div style={{ display: 'flex', borderBottom: '1px solid var(--border-color, #e0e0e0)' }}> mandateId: mandateId,
<button style={tabButtonStyle(leftTab === 'conversations')} onClick={() => setLeftTab('conversations')}>Chats</button> featureInstanceId: instanceId,
<button style={tabButtonStyle(leftTab === 'files')} onClick={() => setLeftTab('files')}>Files</button> };
<button style={tabButtonStyle(leftTab === 'datasources')} onClick={() => setLeftTab('datasources')}>Sources</button>
</div>
<div style={{ flex: 1, overflow: 'auto' }}> const _leftPanelBody = (
{leftTab === 'conversations' && ( <UnifiedDataBar
context={_udbContext}
renderChats={(ctx) => (
<ConversationList <ConversationList
instanceId={instanceId} instanceId={ctx.instanceId}
activeWorkflowId={workspace.workflowId} activeWorkflowId={workspace.workflowId}
onSelect={_handleConversationSelect} onSelect={_handleConversationSelect}
onCreateNew={workspace.resetToNew} onCreateNew={workspace.resetToNew}
refreshTrigger={workspace.workflowVersion} refreshTrigger={workspace.workflowVersion}
/> />
)} )}
{leftTab === 'files' && ( renderFiles={(ctx) => (
<FileBrowser <FileBrowser
instanceId={instanceId} instanceId={ctx.instanceId}
files={workspace.files} files={workspace.files}
onRefresh={workspace.refreshFiles} onRefresh={workspace.refreshFiles}
onFileSelect={_handleFileSelect} onFileSelect={_handleFileSelect}
/> />
)} )}
{leftTab === 'datasources' && ( renderSources={(ctx) => (
<DataSourcePanel <DataSourcePanel
instanceId={instanceId} instanceId={ctx.instanceId}
dataSources={workspace.dataSources} dataSources={workspace.dataSources}
featureDataSources={workspace.featureDataSources} featureDataSources={workspace.featureDataSources}
onRefresh={workspace.refreshDataSources} onRefresh={workspace.refreshDataSources}
onRefreshFeatureDataSources={workspace.refreshFeatureDataSources} onRefreshFeatureDataSources={workspace.refreshFeatureDataSources}
/> />
)} )}
</div> />
</>
); );
const _rightPanelBody = ( const _rightPanelBody = (
@ -386,6 +386,11 @@ export const WorkspacePage: React.FC<WorkspacePageProps> = ({ persistentInstance
Dateien hier ablegen Dateien hier ablegen
</div> </div>
)} )}
<OnboardingAssistant
instanceId={instanceId}
mandateId={mandateId}
featureCode={featureCode}
/>
<ChatStream <ChatStream
messages={workspace.messages} messages={workspace.messages}
agentProgress={workspace.agentProgress} agentProgress={workspace.agentProgress}

View file

@ -9,12 +9,14 @@ import React, { useState } from 'react';
import { useInstanceId } from '../../../hooks/useCurrentInstance'; import { useInstanceId } from '../../../hooks/useCurrentInstance';
import { WorkspaceSettings } from './WorkspaceSettings'; import { WorkspaceSettings } from './WorkspaceSettings';
import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings'; import { WorkspaceGeneralSettings } from './WorkspaceGeneralSettings';
import NeutralizationPanel from './NeutralizationPanel';
type SettingsTab = 'general' | 'voice'; type SettingsTab = 'general' | 'voice' | 'neutralization';
const _TABS: { key: SettingsTab; label: string }[] = [ const _TABS: { key: SettingsTab; label: string }[] = [
{ key: 'general', label: 'Generelle Einstellungen' }, { key: 'general', label: 'Generelle Einstellungen' },
{ key: 'voice', label: 'Sprache & Stimme' }, { key: 'voice', label: 'Sprache & Stimme' },
{ key: 'neutralization', label: 'Neutralisierung' },
]; ];
export const WorkspaceSettingsPage: React.FC = () => { export const WorkspaceSettingsPage: React.FC = () => {
@ -69,6 +71,9 @@ export const WorkspaceSettingsPage: React.FC = () => {
{activeTab === 'voice' && ( {activeTab === 'voice' && (
<WorkspaceSettings instanceId={instanceId} /> <WorkspaceSettings instanceId={instanceId} />
)} )}
{activeTab === 'neutralization' && (
<NeutralizationPanel instanceId={instanceId} />
)}
</div> </div>
</div> </div>
); );