frontend_nyla/src/pages/views/commcoach/CommcoachDossierView.tsx
2026-04-11 00:07:30 +02:00

944 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* CommCoach Dossier View (Main View)
*
* 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, useMandateId } from '../../../hooks/useCurrentInstance';
import {
getDossierExportUrl, getSessionExportUrl,
getScoreHistoryApi, getPersonasApi,
type CoachingPersona,
type SendMessageOptions,
} from '../../../api/commcoachApi';
import api from '../../../api';
import AutoScroll from '../../../components/UiComponents/AutoScroll/AutoScroll';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { UnifiedDataBar } from '../../../components/UnifiedDataBar';
import type { UdbContext, UdbTab } from '../../../components/UnifiedDataBar';
import { ProviderMultiSelect, _defaultProviderSelection } from '../../../components/ProviderSelector';
import type { ProviderSelection } from '../../../components/ProviderSelector';
import { getPageIcon } from '../../../config/pageRegistry';
import styles from './CommcoachDossierView.module.css';
import { useVoiceController } from './useVoiceController';
import { useLanguage } from '../../../providers/language/LanguageContext';
interface WorkspaceFileInfo {
id: string;
fileName: string;
mimeType: string;
fileSize: number;
}
interface DataSourceInfo {
id: string;
connectionId: string;
sourceType: string;
path: string;
label: string;
}
interface FeatureDataSourceInfo {
id: string;
featureInstanceId: string;
featureCode: string;
tableName: string;
label: string;
}
type TabKey = 'coaching' | 'tasks' | 'sessions' | 'scores';
interface CommcoachDossierViewProps {
persistentInstanceId?: string;
persistentMandateId?: string;
}
export const CommcoachDossierView: React.FC<CommcoachDossierViewProps> = ({ persistentInstanceId,
persistentMandateId,
}) => {
const { t } = useLanguage();
const routeInstanceId = useInstanceId();
const routeMandateId = useMandateId();
const instanceId = persistentInstanceId || routeInstanceId;
const mandateId = persistentMandateId || routeMandateId;
const coach = useCommcoach(instanceId);
const { request } = useApiRequest();
const [activeTab, setActiveTab] = useState<TabKey>('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 [udbTab, setUdbTab] = useState<UdbTab>('files');
const [newTaskTitle, setNewTaskTitle] = useState('');
const [scoreHistory, setScoreHistory] = useState<Record<string, Array<{ score: number; createdAt?: string }>>>({});
const [personas, setPersonas] = useState<CoachingPersona[]>([]);
const [selectedPersonaId, setSelectedPersonaId] = useState<string | undefined>(undefined);
const [wsFiles, setWsFiles] = useState<WorkspaceFileInfo[]>([]);
const [wsDataSources, setWsDataSources] = useState<DataSourceInfo[]>([]);
const [wsFeatureDataSources, setWsFeatureDataSources] = useState<FeatureDataSourceInfo[]>([]);
const [attachedFileIds, setAttachedFileIds] = useState<string[]>([]);
const [attachedDsIds, setAttachedDsIds] = useState<string[]>([]);
const [attachedFdsIds, setAttachedFdsIds] = useState<string[]>([]);
const [providerSelection, setProviderSelection] = useState<ProviderSelection>(_defaultProviderSelection);
const [showSourcePicker, setShowSourcePicker] = useState(false);
const [showFilePicker, setShowFilePicker] = useState(false);
const [showAgentActivity, setShowAgentActivity] = useState(true);
const _udbContext: UdbContext | null = instanceId
? { instanceId, mandateId: mandateId || undefined, featureInstanceId: instanceId }
: null;
const inputRef = useRef<HTMLTextAreaElement>(null);
const sendMessageRef = useRef(coach.sendMessage);
sendMessageRef.current = coach.sendMessage;
const attachedFileIdsRef = useRef(attachedFileIds);
attachedFileIdsRef.current = attachedFileIds;
const attachedDsIdsRef = useRef(attachedDsIds);
attachedDsIdsRef.current = attachedDsIds;
const attachedFdsIdsRef = useRef(attachedFdsIds);
attachedFdsIdsRef.current = attachedFdsIds;
const providerSelRef = useRef(providerSelection);
providerSelRef.current = providerSelection;
const voice = useVoiceController({
onFinalText: (text) => {
const opts: SendMessageOptions = {};
if (attachedFileIdsRef.current.length) opts.fileIds = attachedFileIdsRef.current;
if (attachedDsIdsRef.current.length) opts.dataSourceIds = attachedDsIdsRef.current;
if (attachedFdsIdsRef.current.length) opts.featureDataSourceIds = attachedFdsIdsRef.current;
const allowed = providerSelRef.current.include.length > 0 ? providerSelRef.current.include : undefined;
if (allowed) opts.allowedProviders = allowed;
sendMessageRef.current(text, Object.keys(opts).length ? opts : undefined);
},
});
useEffect(() => {
coach.onTtsEventRef.current = (event: TtsEvent) => {
if (event === 'playing') voice.ttsPlaying();
else if (event === 'ended') voice.ttsEnded();
else if (event === 'paused') voice.ttsPaused();
else if (event === 'error') voice.ttsEnded();
};
return () => { coach.onTtsEventRef.current = null; };
}, [coach.onTtsEventRef, voice.ttsPlaying, voice.ttsEnded, voice.ttsPaused]);
// Auto-select first context
useEffect(() => {
if (!coach.selectedContextId && coach.contexts.length > 0) {
coach.selectContext(coach.contexts[0].id, { skipSessionResume: true });
}
}, [coach.contexts, coach.selectedContextId, coach.selectContext]);
// Load scores, personas when context changes
useEffect(() => {
if (!instanceId || !coach.selectedContextId) return;
getScoreHistoryApi(request, instanceId, coach.selectedContextId)
.then(h => setScoreHistory(h))
.catch(() => {});
}, [instanceId, request, coach.selectedContextId]);
useEffect(() => {
if (!instanceId) return;
getPersonasApi(request, instanceId)
.then(p => setPersonas(p))
.catch(() => {});
}, [instanceId, request]);
const _refreshWorkspaceAssets = useCallback(() => {
if (!instanceId) return;
api.get(`/api/workspace/${instanceId}/files`).then(r => setWsFiles(r.data.files || [])).catch(() => {});
api.get(`/api/workspace/${instanceId}/datasources`).then(r => setWsDataSources(r.data.dataSources || [])).catch(() => {});
api.get(`/api/workspace/${instanceId}/feature-datasources`).then(r => setWsFeatureDataSources(r.data.featureDataSources || [])).catch(() => {});
}, [instanceId]);
useEffect(() => {
_refreshWorkspaceAssets();
}, [_refreshWorkspaceAssets]);
useEffect(() => {
const _handleFileUploaded = () => _refreshWorkspaceAssets();
window.addEventListener('fileUploaded', _handleFileUploaded);
return () => window.removeEventListener('fileUploaded', _handleFileUploaded);
}, [_refreshWorkspaceAssets]);
useEffect(() => {
if (activeTab !== 'coaching' || !coach.session) {
voice.deactivate();
} else if (voice.state === 'idle') {
voice.activate();
}
}, [activeTab, coach.session?.id, voice]);
useEffect(() => {
coach.onDocumentCreatedRef.current = () => {
window.dispatchEvent(new CustomEvent('fileUploaded', { detail: { source: 'commcoachDocument' } }));
};
return () => {
coach.onDocumentCreatedRef.current = null;
};
}, [coach, _refreshWorkspaceAssets]);
useEffect(() => {
if (coach.agentToolCalls.length > 0) {
setShowAgentActivity(true);
}
}, [coach.agentToolCalls.length]);
const handleStopTts = useCallback(() => {
coach.stopTts();
voice.ttsStopped();
}, [coach, voice]);
const handlePauseTts = useCallback(() => coach.pauseTts(), [coach]);
const handleResumeTts = useCallback(() => coach.resumeTts(), [coach]);
const handleSend = useCallback(async () => {
if (!coach.inputValue.trim() || coach.isStreaming) return;
const opts: SendMessageOptions = {};
if (attachedFileIds.length) opts.fileIds = attachedFileIds;
if (attachedDsIds.length) opts.dataSourceIds = attachedDsIds;
if (attachedFdsIds.length) opts.featureDataSourceIds = attachedFdsIds;
const allowed = providerSelection.include.length > 0 ? providerSelection.include : undefined;
if (allowed) opts.allowedProviders = allowed;
await coach.sendMessage(coach.inputValue, Object.keys(opts).length ? opts : undefined);
setAttachedFileIds([]);
setShowSourcePicker(false);
setShowFilePicker(false);
}, [coach, attachedFileIds, attachedDsIds, attachedFdsIds, providerSelection]);
const _toggleFile = useCallback((fileId: string) => {
setAttachedFileIds(prev => prev.includes(fileId) ? prev.filter(id => id !== fileId) : [...prev, fileId]);
}, []);
const _toggleDs = useCallback((dsId: string) => {
setAttachedDsIds(prev => prev.includes(dsId) ? prev.filter(id => id !== dsId) : [...prev, dsId]);
}, []);
const _toggleFds = useCallback((fdsId: string) => {
setAttachedFdsIds(prev => prev.includes(fdsId) ? prev.filter(id => id !== fdsId) : [...prev, fdsId]);
}, []);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); }
}, [handleSend]);
const handleCreateContext = useCallback(async () => {
if (!newTitle.trim()) return;
await coach.createContext(newTitle, newDescription || undefined, newCategory);
setNewTitle('');
setNewDescription('');
setNewCategory('custom');
setShowNewContext(false);
}, [newTitle, newDescription, newCategory, coach]);
const handleSelectContext = useCallback((contextId: string) => {
coach.selectContext(contextId, { skipSessionResume: true });
}, [coach]);
const handleAddTask = useCallback(async () => {
if (!newTaskTitle.trim()) return;
await coach.addTask(newTaskTitle);
setNewTaskTitle('');
}, [newTaskTitle, coach]);
if (coach.loadingContexts) {
return <div className={styles.empty}><p>{t('lade')}</p></div>;
}
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 ? t('Seitenleiste einblenden') : t('Seitenleiste ausblenden')}
>
{udbCollapsed ? '\u25B6' : '\u25C0'}
</button>
{!udbCollapsed && (
<UnifiedDataBar
context={_udbContext}
activeTab={udbTab}
onTabChange={setUdbTab}
hideTabs={['chats']}
/>
)}
</div>
)}
{/* Main Content */}
<div className={styles.dossier}>
{/* Context Selector */}
<div className={styles.contextSelector}>
{coach.contexts.map(ctx => (
<button
key={ctx.id}
className={`${styles.contextChip} ${ctx.id === coach.selectedContextId ? styles.contextChipActive : ''}`}
onClick={() => handleSelectContext(ctx.id)}
>
<span className={styles.contextChipIcon}>{_categoryIcon(ctx.category)}</span>
{ctx.title}
</button>
))}
<button
className={styles.contextChipNew}
onClick={() => setShowNewContext(!showNewContext)}
title={t('Neues Thema')}
>
+
</button>
</div>
{/* New Context Form */}
{showNewContext && (
<div className={styles.newContextForm}>
<input
className={styles.newContextInput}
placeholder={t('Thema Titel')}
value={newTitle}
onChange={e => setNewTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreateContext()}
autoFocus
/>
<input
className={styles.newContextInput}
placeholder={t('Beschreibung (optional)')}
value={newDescription}
onChange={e => setNewDescription(e.target.value)}
/>
<select className={styles.newContextInput} value={newCategory} onChange={e => setNewCategory(e.target.value)}>
<option value="custom">Individuell</option>
<option value="leadership">{t('Führung')}</option>
<option value="conflict">Konflikt</option>
<option value="negotiation">Verhandlung</option>
<option value="presentation">{t('Präsentation')}</option>
<option value="feedback">Feedback</option>
<option value="delegation">Delegation</option>
<option value="changeManagement">{t('Change Management')}</option>
</select>
<div className={styles.newContextActions}>
<button className={styles.btnPrimary} onClick={handleCreateContext} disabled={!newTitle.trim() || !!coach.actionLoading}>
{coach.actionLoading === 'creating' ? t('wird erstellt') : t('erstellen')}
</button>
<button className={styles.btnSecondary} onClick={() => setShowNewContext(false)}>{t('Abbrechen')}</button>
</div>
</div>
)}
{/* No context selected */}
{!coach.selectedContextId && !showNewContext && coach.contexts.length === 0 && (
<div className={styles.empty}>
<h3>{t('Willkommen beim Kommunikationscoach')}</h3>
<p>{t('Erstelle ein Thema, um zu')}</p>
<button className={styles.btnPrimary} onClick={() => setShowNewContext(true)}>{t('Neues Thema erstellen')}</button>
</div>
)}
{coach.selectedContextId && (<>
{/* Context Header */}
<div className={styles.header}>
<div>
<h2 className={styles.title}>{coach.selectedContext?.title}</h2>
{coach.selectedContext?.description && (
<p className={styles.description}>{coach.selectedContext.description}</p>
)}
</div>
<div className={styles.headerActions}>
{instanceId && (
<>
<a className={styles.btnExport} href={getDossierExportUrl(instanceId, coach.selectedContextId, 'md')} target="_blank" rel="noopener noreferrer">{t('Export MD')}</a>
<a className={styles.btnExport} href={getDossierExportUrl(instanceId, coach.selectedContextId, 'pdf')} target="_blank" rel="noopener noreferrer">{t('Export PDF')}</a>
</>
)}
<button className={styles.btnArchive} onClick={() => coach.archiveContext(coach.selectedContextId!)} disabled={!!coach.actionLoading}>
{coach.actionLoading === 'archiving' ? t('wird archiviert') : t('archivieren')}
</button>
</div>
</div>
{/* Tab Navigation */}
<div className={styles.tabs}>
{(['coaching', 'tasks', 'sessions', 'scores'] as TabKey[]).map(tab => (
<button
key={tab}
className={`${styles.tab} ${activeTab === tab ? styles.tabActive : ''}`}
onClick={() => setActiveTab(tab)}
>
{_tabLabel(tab, coach, t)}
</button>
))}
</div>
{/* ============================================================ */}
{/* COACHING TAB */}
{/* ============================================================ */}
{activeTab === 'coaching' && (
<div className={styles.coachingTab}>
{!coach.session ? (
<div className={styles.sessionStart}>
<p>{t('Starte eine neue Coachingsession zu')}</p>
{personas.length > 0 && (
<div className={styles.personaSelector}>
<label className={styles.personaLabel}>{t('Gesprächspartner wählen')}</label>
<div className={styles.personaGrid}>
{personas.map(p => (
<button
key={p.id}
className={`${styles.personaChip} ${selectedPersonaId === p.id ? styles.personaChipActive : ''}`}
onClick={() => setSelectedPersonaId(selectedPersonaId === p.id ? undefined : p.id)}
title={p.description}
>
<span className={styles.personaGender}>{p.gender === 'f' ? '\u2640' : p.gender === 'm' ? '\u2642' : '\u25CB'}</span>
<span>{p.label}</span>
</button>
))}
</div>
</div>
)}
<button className={styles.btnPrimary} onClick={() => coach.startSession(selectedPersonaId)} disabled={!!coach.actionLoading}>
{coach.actionLoading === 'starting'
? 'Wird gestartet...'
: selectedPersonaId && personas.find(p => p.id === selectedPersonaId)
? `Session starten mit ${personas.find(p => p.id === selectedPersonaId)!.label}`
: 'Session starten'}
</button>
</div>
) : (
<>
{/* Session Header */}
<div className={styles.sessionHeader}>
<span className={styles.sessionLabel}>{t('Session aktiv')}</span>
<div className={styles.sessionActions}>
{voice.state === 'botSpeaking' && (
<>
<button className={styles.btnSmall} onClick={handlePauseTts}>Pause</button>
<button className={styles.btnSmallDanger} onClick={handleStopTts}>Stop</button>
</>
)}
{voice.state === 'interrupted' && coach.hasAudioToResume() && (
<button className={styles.btnSmall} onClick={handleResumeTts}>Weitersprechen</button>
)}
<button
className={`${styles.btnSmall} ${voice.muted ? styles.mutedActive : ''}`}
onClick={voice.toggleMute}
title={voice.muted ? t('Stummschaltung aufheben') : t('stummschalten')}
>
{voice.muted ? t('🔇 Stumm') : t('🎤 Ton an')}
</button>
<button className={styles.btnSmall} onClick={coach.completeSession} disabled={!!coach.actionLoading}>
{coach.actionLoading === 'completing' ? t('wird abgeschlossen') : t('abschließen')}
</button>
<button className={styles.btnSmallDanger} onClick={coach.cancelSession} disabled={!!coach.actionLoading}>
{coach.actionLoading === 'cancelling' ? t('wird abgebrochen') : t('Abbrechen')}
</button>
</div>
</div>
{/* Messages */}
<AutoScroll scrollDependency={coach.messages.length + (coach.isStreaming ? 1 : 0) + voice.liveTranscript.length}>
<div className={styles.messages}>
{coach.messages.map(msg => (
<div key={msg.id} className={`${styles.message} ${msg.role === 'user' ? styles.messageUser : styles.messageAssistant}`}>
<div className={styles.messageBubble}>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{msg.content}</ReactMarkdown>
</div>
<div className={styles.messageTime}>
{msg.createdAt ? new Date(msg.createdAt).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit' }) : ''}
</div>
</div>
))}
{voice.liveTranscript && (
<div className={`${styles.message} ${styles.messageUser}`}>
<div className={`${styles.messageBubble} ${styles.messageLive}`}>{voice.liveTranscript}</div>
</div>
)}
{coach.isStreaming && (
<div className={`${styles.message} ${styles.messageAssistant}`}>
<div className={styles.messageBubble}>
{coach.streamingMessage ? (
<ReactMarkdown remarkPlugins={[remarkGfm]}>{coach.streamingMessage}</ReactMarkdown>
) : (
<div className={styles.typing}>{coach.streamingStatus || 'Coach denkt nach'}<span className={styles.typingDots}>...</span></div>
)}
</div>
</div>
)}
</div>
</AutoScroll>
{(coach.isStreaming || coach.agentToolCalls.length > 0) && (
<div className={styles.agentActivityPanel}>
<button
className={styles.agentActivityHeader}
onClick={() => setShowAgentActivity(prev => !prev)}
type="button"
>
<span className={styles.agentActivityTitle}>
Agent-Aktivität
{coach.agentToolCalls.length > 0 ? ` (${coach.agentToolCalls.length})` : ''}
</span>
<span className={styles.agentActivityStatus}>
{coach.streamingStatus || (coach.agentToolCalls.length > 0 ? t('Toolaufrufe vorhanden') : t('Warte auf Agent'))}
</span>
<span className={styles.agentActivityChevron}>{showAgentActivity ? '▾' : '▸'}</span>
</button>
{showAgentActivity && (
<div className={styles.agentActivityBody}>
{coach.agentToolCalls.length === 0 ? (
<div className={styles.agentActivityEmpty}>
Noch keine Tool-Aufrufe in dieser Antwort.
</div>
) : (
coach.agentToolCalls.map((toolCall, idx) => (
<div key={`${toolCall.toolName}-${idx}`} className={styles.agentActivityItem}>
<div className={styles.agentActivityItemHeader}>
<span className={styles.agentActivityToolName}>{toolCall.toolName}</span>
<span
className={`${styles.agentActivityBadge} ${
toolCall.success === true
? styles.agentActivityBadgeSuccess
: toolCall.success === false
? styles.agentActivityBadgeError
: styles.agentActivityBadgeRunning
}`}
>
{toolCall.success === true ? 'fertig' : toolCall.success === false ? 'fehler' : 'läuft'}
</span>
</div>
{toolCall.args && (
<div className={styles.agentActivityMeta}>
<strong>Args:</strong> {_formatToolPayload(toolCall.args)}
</div>
)}
{toolCall.result && (
<div className={styles.agentActivityMeta}>
<strong>Result:</strong> {toolCall.result}
</div>
)}
</div>
))
)}
</div>
)}
</div>
)}
{/* Input Area */}
<div className={styles.inputArea}>
<div className={styles.voiceStatus}>
<span className={`${styles.voiceIndicator} ${voice.state === 'listening' ? styles.voiceActive : ''}`}>
{voice.muted
? 'Stumm Mikrofon aus'
: voice.state === 'botSpeaking'
? (coach.streamingStatus || 'Coach spricht...')
: coach.isStreaming
? (coach.streamingStatus || 'Coach denkt nach...')
: voice.state === 'interrupted'
? 'Unterbrochen Mikrofon an'
: voice.state === 'listening'
? (voice.liveTranscript ? 'Spricht...' : 'Mikrofon an bitte sprechen')
: 'Mikrofon wird gestartet...'}
</span>
</div>
{/* Attachment Chips */}
{(attachedFileIds.length > 0 || attachedDsIds.length > 0 || attachedFdsIds.length > 0) && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', paddingBottom: 4 }}>
{attachedFileIds.map(fId => {
const file = wsFiles.find(f => f.id === fId);
return (
<span key={fId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#e3f2fd', color: '#1565c0', fontWeight: 500,
}}>
{file?.fileName || fId}
<button onClick={() => _toggleFile(fId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#1565c0', padding: 0, lineHeight: 1 }}>×</button>
</span>
);
})}
{attachedDsIds.map(dsId => {
const ds = wsDataSources.find(d => d.id === dsId);
return (
<span key={dsId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#e8f5e9', color: '#2e7d32', fontWeight: 500,
}}>
{ds?.label || ds?.path || dsId}
<button onClick={() => _toggleDs(dsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#2e7d32', padding: 0, lineHeight: 1 }}>×</button>
</span>
);
})}
{attachedFdsIds.map(fdsId => {
const fds = wsFeatureDataSources.find(d => d.id === fdsId);
return (
<span key={fdsId} style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
padding: '2px 8px', borderRadius: 12, fontSize: 11,
background: '#f3e5f5', color: '#7b1fa2', fontWeight: 500,
}}>
<span style={{ fontSize: 12 }}>{fds ? getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F' : '\uD83D\uDDC3\uFE0F'}</span>
{fds?.label || fdsId}
<button onClick={() => _toggleFds(fdsId)} style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: 11, color: '#7b1fa2', padding: 0, lineHeight: 1 }}>×</button>
</span>
);
})}
</div>
)}
<div className={styles.textInputRow}>
<textarea
ref={inputRef}
className={styles.textInput}
placeholder={t('Nachricht eingeben')}
value={coach.inputValue}
onChange={e => coach.setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
rows={1}
disabled={coach.isStreaming}
/>
{/* File Picker */}
{wsFiles.length > 0 && (
<div style={{ position: 'relative' }}>
<button
onClick={() => { setShowFilePicker(v => !v); setShowSourcePicker(false); }}
disabled={coach.isStreaming}
title={t('Datei anhängen')}
style={{
width: 36, height: 36, borderRadius: 8,
border: `1px solid ${attachedFileIds.length ? '#1565c0' : 'var(--border-color, #ddd)'}`,
background: attachedFileIds.length ? '#e3f2fd' : 'var(--secondary-bg, #f5f5f5)',
color: attachedFileIds.length ? '#1565c0' : '#666',
cursor: coach.isStreaming ? 'not-allowed' : 'pointer',
fontSize: 15, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: coach.isStreaming ? 0.5 : 1, position: 'relative',
}}
>
+
{attachedFileIds.length > 0 && (
<span style={{ position: 'absolute', top: -4, right: -4, background: '#1565c0', color: '#fff', fontSize: 9, fontWeight: 700, borderRadius: '50%', width: 15, height: 15, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{attachedFileIds.length}
</span>
)}
</button>
{showFilePicker && (
<div style={{
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
background: 'var(--bg-card, #fff)', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 220, maxHeight: 240, overflowY: 'auto',
}}>
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>{t('Dateien anhängen')}</div>
{wsFiles.map(f => {
const sel = attachedFileIds.includes(f.id);
return (
<div key={f.id} onClick={() => _toggleFile(f.id)} style={{
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: sel ? '#e3f2fd' : 'transparent',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
>
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #1565c0' : '2px solid #ccc', background: sel ? '#1565c0' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
{sel ? '✓' : ''}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{f.fileName}</span>
</div>
);
})}
</div>
)}
</div>
)}
{/* Source Picker */}
{(wsDataSources.length > 0 || wsFeatureDataSources.length > 0) && (
<div style={{ position: 'relative' }}>
<button
onClick={() => { setShowSourcePicker(v => !v); setShowFilePicker(false); }}
disabled={coach.isStreaming}
title={t('Datenquellen anhängen')}
style={{
width: 36, height: 36, borderRadius: 8,
border: `1px solid ${(attachedDsIds.length + attachedFdsIds.length) ? '#2e7d32' : 'var(--border-color, #ddd)'}`,
background: (attachedDsIds.length + attachedFdsIds.length) ? '#e8f5e9' : 'var(--secondary-bg, #f5f5f5)',
color: (attachedDsIds.length + attachedFdsIds.length) ? '#2e7d32' : '#666',
cursor: coach.isStreaming ? 'not-allowed' : 'pointer',
fontSize: 14, display: 'flex', alignItems: 'center', justifyContent: 'center',
opacity: coach.isStreaming ? 0.5 : 1, position: 'relative',
}}
>
&#128279;
{(attachedDsIds.length + attachedFdsIds.length) > 0 && (
<span style={{ position: 'absolute', top: -4, right: -4, background: '#2e7d32', color: '#fff', fontSize: 9, fontWeight: 700, borderRadius: '50%', width: 15, height: 15, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{attachedDsIds.length + attachedFdsIds.length}
</span>
)}
</button>
{showSourcePicker && (
<div style={{
position: 'absolute', bottom: '100%', right: 0, marginBottom: 4,
background: 'var(--bg-card, #fff)', border: '1px solid var(--border-color, #e0e0e0)',
borderRadius: 8, boxShadow: '0 -2px 8px rgba(0,0,0,0.1)', zIndex: 20,
minWidth: 240, maxHeight: 260, overflowY: 'auto',
}}>
{wsDataSources.length > 0 && (
<>
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderBottom: '1px solid #f0f0f0' }}>{t('Persönliche Quellen')}</div>
{wsDataSources.map(ds => {
const sel = attachedDsIds.includes(ds.id);
return (
<div key={ds.id} onClick={() => _toggleDs(ds.id)} style={{
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: sel ? '#e8f5e9' : 'transparent',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
>
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #2e7d32' : '2px solid #ccc', background: sel ? '#2e7d32' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
{sel ? '✓' : ''}
</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{ds.label || ds.path}</span>
</div>
);
})}
</>
)}
{wsFeatureDataSources.length > 0 && (
<>
<div style={{ padding: '6px 12px', fontSize: 11, color: '#999', fontWeight: 600, borderTop: wsDataSources.length ? '1px solid #f0f0f0' : 'none', borderBottom: '1px solid #f0f0f0' }}>Feature-Datenquellen</div>
{wsFeatureDataSources.map(fds => {
const sel = attachedFdsIds.includes(fds.id);
return (
<div key={fds.id} onClick={() => _toggleFds(fds.id)} style={{
padding: '6px 12px', cursor: 'pointer', fontSize: 12,
display: 'flex', alignItems: 'center', gap: 8,
background: sel ? '#f3e5f5' : 'transparent',
}}
onMouseEnter={e => { if (!sel) e.currentTarget.style.background = '#f5f5f5'; }}
onMouseLeave={e => { if (!sel) e.currentTarget.style.background = ''; }}
>
<span style={{ width: 14, height: 14, borderRadius: 3, border: sel ? '2px solid #7b1fa2' : '2px solid #ccc', background: sel ? '#7b1fa2' : 'transparent', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 9, fontWeight: 700, flexShrink: 0 }}>
{sel ? '✓' : ''}
</span>
<span style={{ display: 'flex', alignItems: 'center', fontSize: 12, color: '#7b1fa2', flexShrink: 0 }}>{getPageIcon(`feature.${fds.featureCode}`) || '\uD83D\uDDC3\uFE0F'}</span>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{fds.label} {fds.tableName}</span>
</div>
);
})}
</>
)}
</div>
)}
</div>
)}
{/* Provider Selector */}
<ProviderMultiSelect
selection={providerSelection}
onChange={setProviderSelection}
showLabel={false}
disabled={coach.isStreaming}
/>
<button className={styles.sendBtn} onClick={handleSend} disabled={!coach.inputValue.trim() || coach.isStreaming}>
Senden
</button>
</div>
</div>
</>
)}
{coach.error && <div className={styles.errorBanner}>{coach.error}</div>}
</div>
)}
{/* ============================================================ */}
{/* TASKS TAB */}
{/* ============================================================ */}
{activeTab === 'tasks' && (
<div className={styles.tabContent}>
<div className={styles.addTaskRow}>
<input
className={styles.addTaskInput}
placeholder={t('Neue Aufgabe')}
value={newTaskTitle}
onChange={e => setNewTaskTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAddTask()}
/>
<button className={styles.addTaskBtn} onClick={handleAddTask} disabled={!newTaskTitle.trim() || !!coach.actionLoading}>
{coach.actionLoading === 'addingTask' ? t('wird hinzugefügt') : t('hinzufügen')}
</button>
</div>
{coach.tasks.length === 0 ? (
<div className={styles.emptyTab}>{t('Noch keine Aufgaben der Coach')}</div>
) : (
<div className={styles.taskList}>
{coach.tasks.map(task => (
<div key={task.id} className={`${styles.taskItem} ${task.status === 'done' ? styles.taskDone : ''}`}>
<button className={styles.taskCheck} onClick={() => coach.toggleTaskStatus(task.id, task.status)}>
{task.status === 'done' ? '\u2713' : '\u25CB'}
</button>
<div className={styles.taskContent}>
<div className={styles.taskTitle}>{task.title}</div>
{task.description && <div className={styles.taskDesc}>{task.description}</div>}
</div>
<div className={styles.taskMeta}>
<span className={`${styles.taskPriority} ${styles[`priority_${task.priority}`]}`}>{task.priority}</span>
</div>
<button className={styles.taskDelete} onClick={() => coach.removeTask(task.id)}>x</button>
</div>
))}
</div>
)}
</div>
)}
{/* ============================================================ */}
{/* SESSIONS TAB */}
{/* ============================================================ */}
{activeTab === 'sessions' && (
<div className={styles.tabContent}>
{coach.sessions.length === 0 ? (
<div className={styles.emptyTab}>{t('Noch keine abgeschlossenen Sessions')}</div>
) : (
<div className={styles.sessionTimeline}>
{coach.sessions.map(s => (
<div key={s.id} className={styles.sessionItem}>
<div className={styles.sessionItemHeader}>
<span className={`${styles.sessionStatus} ${styles[`status_${s.status}`]}`}>
{s.status === 'completed' ? 'Abgeschlossen' : s.status === 'active' ? t('aktiv') : t('Abgebrochen')}
</span>
<span className={styles.sessionDate}>{s.startedAt ? new Date(s.startedAt).toLocaleDateString('de-CH') : ''}</span>
{s.competenceScore != null && <span className={styles.sessionScore}>Score: {Math.round(s.competenceScore)}</span>}
</div>
{s.summary && (
<div className={styles.sessionSummary}><ReactMarkdown>{s.summary}</ReactMarkdown></div>
)}
<div className={styles.sessionMeta}>
{s.messageCount} Nachrichten | {Math.round(s.durationSeconds / 60)} Min.
{s.personaId && <span> {t('Persona')}</span>}
{instanceId && s.status === 'completed' && (
<a className={styles.sessionExport} href={getSessionExportUrl(instanceId, s.id, 'md')} target="_blank" rel="noopener noreferrer" onClick={e => e.stopPropagation()}>Export</a>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ============================================================ */}
{/* SCORES TAB */}
{/* ============================================================ */}
{activeTab === 'scores' && (
<div className={styles.tabContent}>
{coach.scores.length === 0 ? (
<div className={styles.emptyTab}>{t('Noch keine Bewertungen, schließe eine')}</div>
) : (
<div className={styles.scoreList}>
{_groupScoresByDimension(coach.scores).map(group => (
<div key={group.dimension} className={styles.scoreGroup}>
<div className={styles.scoreDimension}>
<span className={styles.scoreDimensionLabel}>{_dimensionLabel(group.dimension)}</span>
<span className={styles.scoreLatest}>{Math.round(group.latest.score)}/100</span>
<span className={`${styles.scoreTrend} ${styles[`trend_${group.latest.trend}`]}`}>
{group.latest.trend === 'improving' ? 'steigend' : group.latest.trend === 'declining' ? 'sinkend' : 'stabil'}
</span>
</div>
<div className={styles.scoreBar}><div className={styles.scoreBarFill} style={{ width: `${group.latest.score}%` }} /></div>
{group.latest.evidence && <div className={styles.scoreEvidence}>{group.latest.evidence}</div>}
{scoreHistory[group.dimension] && scoreHistory[group.dimension].length > 1 && (
<div className={styles.scoreHistory}>
<div className={styles.scoreHistoryLabel}>Verlauf:</div>
<div className={styles.scoreHistoryPoints}>
{scoreHistory[group.dimension].map((entry, i) => (
<span key={i} className={styles.scoreHistoryPoint} title={entry.createdAt || ''}>{Math.round(entry.score)}</span>
))}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</>)}
</div>
</div>
);
};
function _categoryIcon(category: string): string {
const icons: Record<string, string> = {
leadership: 'L', conflict: 'K', negotiation: 'V',
presentation: 'P', feedback: 'F', delegation: 'D',
changeManagement: 'C', custom: '*',
};
return icons[category] || '*';
}
function _tabLabel(tab: TabKey, coach: any, t: (key: string) => string): string {
switch (tab) {
case 'coaching': return coach.session ? t('Coaching aktiv') : t('Coaching');
case 'tasks': return `${t('Aufgaben')} (${coach.tasks.length})`;
case 'sessions': return `Sessions (${coach.sessions.length})`;
case 'scores': return `${t('Bewertungen')} (${coach.scores.length})`;
}
}
interface ScoreGroup {
dimension: string;
latest: { score: number; trend: string; evidence?: string; createdAt?: string };
history: Array<{ score: number; createdAt?: string }>;
}
function _groupScoresByDimension(scores: any[]): ScoreGroup[] {
const groups: Record<string, ScoreGroup> = {};
for (const s of scores) {
const dim = s.dimension;
if (!groups[dim]) groups[dim] = { dimension: dim, latest: s, history: [] };
groups[dim].history.push({ score: s.score, createdAt: s.createdAt });
if (s.createdAt > (groups[dim].latest.createdAt || '')) groups[dim].latest = s;
}
return Object.values(groups);
}
function _dimensionLabel(dim: string): string {
const labels: Record<string, string> = {
empathy: 'Einfühlungsvermögen', clarity: 'Klarheit',
assertiveness: 'Durchsetzung', listening: 'Zuhören',
selfReflection: 'Selbstreflexion',
};
return labels[dim] || dim;
}
function _formatToolPayload(payload: Record<string, unknown>): string {
try {
const serialized = JSON.stringify(payload);
if (!serialized) return '';
return serialized.length > 180 ? `${serialized.slice(0, 177)}...` : serialized;
} catch {
return '[unlesbar]';
}
}
export default CommcoachDossierView;